1use inillucent_driver::{Status, Support};
17use inillucent_value::Value;
18
19use super::outcome::{columns_from, table, Column, Failed, Outcome};
20use super::{Arguments, Context};
21use crate::json::{self, Json};
22
23pub fn value_to_json(value: &Value<'static>) -> Json {
39 match value {
40 Value::Null => Json::Null,
41 Value::Integer(number) => Json::Int(*number),
42 Value::Real(number) => Json::Real(*number),
43 Value::Text(text) => json::text(String::from_utf8_lossy(text.raw()).into_owned()),
44 Value::Blob(bytes) => {
45 let mut hex = String::with_capacity(bytes.raw().len().saturating_mul(2));
46 for byte in bytes.raw() {
47 hex.push_str(&format!("{byte:02x}"));
48 }
49 json::object(vec![("blob", json::text(hex))])
50 }
51 }
52}
53
54fn literal_of(value: &Json) -> Result<String, Failed> {
62 match value {
63 Json::Null => Ok("NULL".to_string()),
64 Json::Bool(true) => Ok("1".to_string()),
65 Json::Bool(false) => Ok("0".to_string()),
66 Json::Int(number) => Ok(number.to_string()),
67 Json::Real(number) if number.is_finite() => Ok(format!("{number:?}")),
68 Json::Real(_) => Err(Failed::misuse(
69 "a parameter cannot be NaN or infinity: SQL has no spelling for either.",
70 )),
71 Json::Text(text) if is_blob_literal(text) => Ok(text.clone()),
74 Json::Text(text) => Ok(format!("'{}'", text.replace('\'', "''"))),
75 Json::Array(values) => vector_literal(values),
81 Json::Object(fields) => blob_literal(fields),
85 }
86}
87
88fn vector_literal(values: &[Json]) -> Result<String, Failed> {
95 if values.is_empty() {
96 return Err(Failed::misuse(
97 "a parameter that is an array is a vector, so it needs at least one number.",
98 ));
99 }
100 let mut hex = String::from("x'");
101 for value in values {
102 let number = match value {
103 Json::Int(whole) => *whole as f64,
104 Json::Real(real) if real.is_finite() => *real,
105 _ => {
106 return Err(Failed::misuse(
107 "a parameter that is an array is a vector, so every element has to be a \
108 finite number.",
109 ))
110 }
111 };
112 for byte in (number as f32).to_le_bytes() {
113 hex.push_str(&format!("{byte:02x}"));
114 }
115 }
116 hex.push('\'');
117 Ok(hex)
118}
119
120fn blob_literal(fields: &[(String, Json)]) -> Result<String, Failed> {
124 let held = fields
125 .iter()
126 .find(|(name, _)| name == "blob")
127 .map(|(_, value)| value);
128 let Some(Json::Text(hex)) = held else {
129 return Err(Failed::misuse(
130 "a parameter has to be a string, a number, a boolean, null, an array of numbers \
131 for a vector, or {\"blob\": \"<hex>\"} for bytes.",
132 ));
133 };
134 if hex.is_empty() || hex.len() % 2 != 0 || !hex.chars().all(|digit| digit.is_ascii_hexdigit()) {
135 return Err(Failed::misuse(
136 "the value of \"blob\" has to be an even number of hexadecimal digits.",
137 ));
138 }
139 Ok(format!("x'{hex}'"))
140}
141
142fn is_blob_literal(text: &str) -> bool {
146 let Some(inner) = text
147 .strip_prefix("x'")
148 .and_then(|rest| rest.strip_suffix('\''))
149 else {
150 return false;
151 };
152 !inner.is_empty()
153 && inner.len() % 2 == 0
154 && inner.chars().all(|digit| digit.is_ascii_hexdigit())
155}
156
157fn quoted(name: &str) -> String {
161 format!("\"{}\"", name.replace('"', "\"\""))
162}
163
164fn quoted_text(text: &str) -> String {
168 format!("'{}'", text.replace('\'', "''"))
169}
170
171fn produce(
185 context: &mut Context,
186 command: &str,
187 sql: &str,
188 params: &[Json],
189 limit: usize,
190) -> Result<Outcome, Failed> {
191 context.refuse_if_it_writes(sql)?;
192 refuse_a_script(context, command, sql)?;
193 let mut bound = Vec::with_capacity(params.len());
194 for value in params {
195 let literal = literal_of(value)?;
196 let held = context
197 .shell()
198 .collect(&format!("SELECT {literal}"))
199 .map_err(|failure| Failed::from_shell(&failure))?
200 .1
201 .first()
202 .and_then(|row| row.first())
203 .cloned()
204 .unwrap_or(Value::Null);
205 bound.push(inillucent_tree::datum::OwnedDatum::from(&held));
206 }
207 let started = std::time::Instant::now();
208 let collected = context.shell().collect_bound(sql, &bound);
209 let elapsed = started.elapsed().as_secs_f64() * 1000.0;
210 let (names, rows) = collected.map_err(|failure| Failed::from_shell(&failure))?;
211 Ok(rows_to_outcome(
212 context, command, names, rows, limit, elapsed,
213 ))
214}
215
216fn rows_to_outcome(
225 context: &mut Context,
226 command: &str,
227 names: Vec<String>,
228 rows: Vec<Vec<Value<'static>>>,
229 limit: usize,
230 elapsed: f64,
231) -> Outcome {
232 let total = rows.len();
233 let kept = if limit == 0 { total } else { limit.min(total) };
234 let cells: Vec<Vec<Json>> = rows
235 .iter()
236 .take(kept)
237 .map(|row| row.iter().map(value_to_json).collect())
238 .collect();
239 let columns = columns_from(&names, &cells);
240 let connection = context.shell().connection();
241 let changes = connection.total_changes().unwrap_or_default();
242 let rowid = connection.last_insert_rowid().unwrap_or_default();
243 let _ = connection;
244 let mut text = table(&columns, &cells, &context.null);
245 if kept < total {
246 text.push_str(&format!("\n({kept} of {total} rows)"));
247 }
248 Outcome {
249 command: command.to_string(),
250 columns,
251 rows: cells,
252 total,
253 more: kept < total,
254 changes,
255 last_insert_rowid: rowid,
256 elapsed_ms: elapsed,
257 text,
258 extra: Vec::new(),
259 }
260}
261
262fn limit_of(context: &Context, arguments: &Arguments) -> Result<usize, Failed> {
270 let asked = match arguments.integer("limit") {
271 Some(asked) if asked < 0 => {
276 return Err(Failed::misuse(format!(
277 "limit={asked} is not a number of rows. Write 0 for every row, or a positive \
278 count."
279 )))
280 }
281 Some(asked) => asked as usize,
282 None => context.limit,
283 };
284 context.cap_rows(asked)
285}
286
287fn refuse_a_script(context: &mut Context, command: &str, sql: &str) -> Result<(), Failed> {
299 let Some(rest) = context.shell().trailing_statement(sql) else {
300 return Ok(());
301 };
302 Err(Failed::said(
303 Status::InvalidState,
304 format!(
305 "{command} runs one statement and this is several; the next one begins {rest:?}. \
306 Use `batch`, which runs them all in one transaction."
307 ),
308 ))
309}
310
311fn bound_values(context: &Context, arguments: &Arguments) -> Result<Vec<Json>, Failed> {
328 let inline = arguments.values("params");
329 let Some(named) = arguments.text("params-file") else {
330 return Ok(inline);
331 };
332 if !inline.is_empty() {
333 return Err(Failed::misuse(
334 "give the values in 'params' or in 'params-file', not both.",
335 ));
336 }
337 let text = match named {
338 "-" if context.confined() => {
343 return Err(Failed::said(
344 Status::InvalidState,
345 "this surface is confined to a directory with --root, and '-' reads the \
346 parameters from standard input, which such a surface does not have to itself. \
347 Write them with 'params', or name a file inside the root.",
348 ))
349 }
350 "-" => {
351 let mut held = String::new();
352 std::io::Read::read_to_string(&mut std::io::stdin(), &mut held)
353 .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
354 held
355 }
356 path => {
365 let admitted = context.confine(path)?;
366 std::fs::read_to_string(&admitted)
367 .map_err(|error| Failed::said(Status::Io, format!("{path}: {error}")))?
368 }
369 };
370 if text.len() > MAX_PARAMS_FILE_BYTES {
376 return Err(Failed::said(
377 Status::TooBig,
378 format!(
379 "'params-file' is {} bytes, past the {MAX_PARAMS_FILE_BYTES} byte limit. \
380 Parameters are a list of values, not a data file.",
381 text.len()
382 ),
383 ));
384 }
385 let parsed = json::parse(text.trim())
386 .map_err(|why| Failed::misuse(format!("'params-file' is not JSON: {why}")))?;
387 match parsed {
388 Json::Array(values) => Ok(values),
389 _ => Err(Failed::misuse("'params-file' has to hold a JSON array.")),
390 }
391}
392
393const MAX_PARAMS_FILE_BYTES: usize = 1024 * 1024;
399
400pub fn query(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
402 let sql = arguments.required_text("sql")?.to_string();
403 let params = bound_values(context, arguments)?;
404 let limit = limit_of(context, arguments)?;
405 produce(context, "query", &sql, ¶ms, limit)
406}
407
408pub fn exec(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
410 let sql = arguments.required_text("sql")?.to_string();
411 let params = bound_values(context, arguments)?;
412 let before = context
413 .shell()
414 .connection()
415 .total_changes()
416 .map_err(|error| Failed::from_engine(&error))?;
417 let mut produced = produce(context, "exec", &sql, ¶ms, 0)?;
418 let after = context
419 .shell()
420 .connection()
421 .total_changes()
422 .map_err(|error| Failed::from_engine(&error))?;
423 produced.changes = after - before;
424 produced.text = match produced.rows.is_empty() {
425 true => format!(
426 "ok. {} row{} changed.",
427 produced.changes,
428 if produced.changes == 1 { "" } else { "s" }
429 ),
430 false => produced.text.clone(),
433 };
434 Ok(produced)
435}
436
437pub fn batch(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
456 let sql = arguments.required_text("sql")?.to_string();
457 context.refuse_if_it_writes(&sql)?;
458 let joined = !context
459 .shell()
460 .connection()
461 .autocommit()
462 .map_err(|error| Failed::from_engine(&error))?;
463 let before = context
464 .shell()
465 .connection()
466 .total_changes()
467 .map_err(|error| Failed::from_engine(&error))?;
468 if !joined {
469 context
470 .shell()
471 .execute("BEGIN")
472 .map_err(|message| Failed::said(Status::Syntax, message))?;
473 }
474 let ran = context.shell().connection().execute_batch(&sql);
481 if let Err(error) = ran {
482 let failed = Failed::from_engine(&error);
483 if !joined {
484 if let Err(second) = context.shell().execute("ROLLBACK") {
490 return Err(Failed {
491 message: format!("{} (and the rollback failed: {second})", failed.message),
492 ..failed
493 });
494 }
495 }
496 return Err(failed);
497 }
498 if !joined {
499 context
500 .shell()
501 .execute("COMMIT")
502 .map_err(|message| Failed::said(Status::Syntax, message))?;
503 }
504 let after = context
505 .shell()
506 .connection()
507 .total_changes()
508 .map_err(|error| Failed::from_engine(&error))?;
509 let changes = after - before;
510 let mut produced = Outcome::said(
511 "batch",
512 format!(
513 "ok. {changes} row{} changed.",
514 if changes == 1 { "" } else { "s" }
515 ),
516 );
517 produced.changes = changes;
518 produced.extra.push((
519 "transaction".to_string(),
520 Json::Text(
521 if joined {
522 "joined the open transaction; not committed"
523 } else {
524 "committed"
525 }
526 .to_string(),
527 ),
528 ));
529 Ok(produced)
530}
531
532fn take_failure(context: &mut Context, printed: &str) -> Option<Failed> {
544 let shell = context.shell();
545 let failed = std::mem::replace(&mut shell.failed, false);
546 let error = shell.first_error.take();
547 if !failed {
548 return None;
549 }
550 let message = printed.trim_end().to_string();
551 Some(match error {
552 Some(error) => Failed {
553 message,
554 ..Failed::from_engine(&error)
555 },
556 None => Failed::said(Status::Syntax, message),
557 })
558}
559
560pub fn run_input(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
562 let input = arguments.required_text("input")?.to_string();
563 if context.readonly() {
564 for line in input.lines() {
565 let trimmed = line.trim();
566 if trimmed.is_empty() || trimmed.starts_with('.') {
567 continue;
568 }
569 context.refuse_if_it_writes(trimmed)?;
570 }
571 }
572 let printed = context.collect_output(&input);
573 if let Some(failed) = take_failure(context, &printed) {
582 return Err(failed);
583 }
584 Ok(Outcome::said("run", printed.trim_end()))
585}
586
587pub fn create(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
589 let path = arguments.required_text("path")?.to_string();
590 let confined = context.confine(&path)?;
591 if confined.exists() {
592 return Err(Failed::said(
593 Status::InvalidState,
594 format!("\"{path}\" already exists. Open it instead of creating it."),
595 ));
596 }
597 let named = confined.to_string_lossy().into_owned();
598 context.use_database(&named)?;
599 context
603 .shell()
604 .execute("PRAGMA user_version = 0")
605 .map_err(|message| Failed::said(Status::Io, message))?;
606 Ok(Outcome::said("create", format!("created {named}")).with("path", json::text(&named)))
607}
608
609fn listing(context: &mut Context, command: &str, sql: &str) -> Result<Outcome, Failed> {
615 produce(context, command, sql, &[], 0)
616}
617
618pub fn tables(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
620 let mut sql = String::from(
621 "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') \
622 AND name NOT LIKE 'sqlite_%'",
623 );
624 if let Some(pattern) = arguments.text("pattern") {
625 sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
626 }
627 sql.push_str(" ORDER BY name");
628 listing(context, "tables", &sql)
629}
630
631pub fn indexes(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
641 let pattern = arguments.text("pattern").map(str::to_string);
642 let mut sql =
643 String::from("SELECT name, tbl_name AS \"table\" FROM sqlite_master WHERE type = 'index'");
644 if let Some(pattern) = &pattern {
645 sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
646 }
647 context.refuse_if_it_writes(&sql)?;
648 let started = std::time::Instant::now();
649 let (names, mut rows) = context
650 .shell()
651 .collect(&sql)
652 .map_err(|failure| Failed::from_shell(&failure))?;
653 rows.extend(module_indexes(context, pattern.as_deref())?);
654 rows.sort_by(|left, right| {
655 let key = |row: &Vec<Value<'static>>| {
656 (
657 row.get(1).map(text_of_value).unwrap_or_default(),
658 row.first().map(text_of_value).unwrap_or_default(),
659 )
660 };
661 key(left).cmp(&key(right))
662 });
663 let elapsed = started.elapsed().as_secs_f64() * 1000.0;
664 Ok(rows_to_outcome(context, "indexes", names, rows, 0, elapsed))
665}
666
667fn module_indexes(
676 context: &mut Context,
677 pattern: Option<&str>,
678) -> Result<Vec<Vec<Value<'static>>>, Failed> {
679 let tables = context
680 .shell()
681 .column("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'");
682 let mut found = Vec::new();
683 for table in tables {
684 let listed = context
685 .shell()
686 .collect(&format!("PRAGMA index_list({})", quoted_text(&table)))
687 .map_err(|failure| Failed::from_shell(&failure))?
688 .1;
689 for row in listed {
690 if row.get(3).map(text_of_value).as_deref() != Some("v") {
691 continue;
692 }
693 let Some(name) = row.get(1).map(text_of_value) else {
694 continue;
695 };
696 if let Some(pattern) = pattern {
697 if !like(&name, pattern) {
698 continue;
699 }
700 }
701 let (Ok(named), Ok(owner)) = (
702 Value::owned_text(name.as_bytes()),
703 Value::owned_text(table.as_bytes()),
704 ) else {
705 continue;
706 };
707 found.push(vec![named, owner]);
708 }
709 }
710 Ok(found)
711}
712
713fn text_of_value(value: &Value<'static>) -> String {
717 match value {
718 Value::Text(text) => String::from_utf8_lossy(text.raw()).into_owned(),
719 _ => String::new(),
720 }
721}
722
723fn like(name: &str, pattern: &str) -> bool {
732 let folded = name.to_lowercase();
733 let wanted = pattern.to_lowercase();
734 let parts: Vec<&str> = wanted.split('%').collect();
735 let mut at = 0usize;
736 for (which, part) in parts.iter().enumerate() {
737 if part.is_empty() {
738 continue;
739 }
740 let Some(found) = folded.get(at..).and_then(|rest| rest.find(part)) else {
741 return false;
742 };
743 if which == 0 && !wanted.starts_with('%') && found != 0 {
744 return false;
745 }
746 at = at.saturating_add(found).saturating_add(part.len());
747 }
748 if !wanted.ends_with('%') {
749 if let Some(last) = parts.last() {
750 if !last.is_empty() && at != folded.len() {
751 return false;
752 }
753 }
754 }
755 true
756}
757
758pub fn databases(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
760 listing(context, "databases", "PRAGMA database_list")
761}
762
763pub fn schema(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
765 let mut line = String::from(".schema");
766 if arguments.flag("indent") {
767 line.push_str(" --indent");
768 }
769 if let Some(pattern) = arguments.text("pattern") {
770 line.push(' ');
771 line.push_str(pattern);
772 }
773 let printed = context.collect_output(&line);
774 context.shell().failed = false;
775 context.shell().first_error = None;
776 Ok(Outcome::said("schema", printed.trim_end()))
777}
778
779pub fn describe(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
795 let name = arguments.required_text("table")?.to_string();
796 let info = format!(
797 "SELECT cid, name, type, \"notnull\", dflt_value, pk, CASE hidden WHEN 1 THEN 'hidden' WHEN 2 THEN 'generated virtual' WHEN 3 THEN 'generated stored' ELSE '' END AS kind FROM pragma_table_xinfo({})",
798 quoted_text(&name)
799 );
800 let mut produced = produce(context, "describe", &info, &[], 0)?;
801 if produced.rows.is_empty() {
802 return Err(Failed::said(
803 Status::NotFound,
804 format!("no such table: {name}"),
805 ));
806 }
807 let ddl = context
808 .shell()
809 .scalar(&format!(
810 "SELECT sql FROM sqlite_master WHERE name = {}",
811 quoted_text(&name)
812 ))
813 .unwrap_or_default();
814 let index_rows = context.shell().column(&format!(
815 "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = {} ORDER BY name",
816 quoted_text(&name)
817 ));
818 let count = context
819 .shell()
820 .scalar(&format!("SELECT count(*) FROM {}", quoted(&name)))
821 .unwrap_or_default();
822 let indexes: Vec<Json> = index_rows.iter().map(json::text).collect();
823 let drawn = table(&produced.columns, &produced.rows, &context.null);
824 produced.text = format!(
825 "{name}: {} column{}, {count} row{}\n\n{drawn}\n\nindexes: {}\n\n{ddl}",
826 produced.rows.len(),
827 if produced.rows.len() == 1 { "" } else { "s" },
828 if count == "1" { "" } else { "s" },
829 match index_rows.is_empty() {
830 true => "none".to_string(),
831 false => index_rows.join(", "),
832 }
833 );
834 Ok(produced
835 .with("table", json::text(&name))
836 .with("ddl", json::text(ddl))
837 .with("indexes", Json::Array(indexes))
838 .with(
839 "row_count_in_table",
840 Json::Int(count.parse::<i64>().unwrap_or(-1)),
841 ))
842}
843
844pub fn explain(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
846 let sql = arguments.required_text("sql")?.to_string();
847 let plan = context
848 .shell()
849 .connection()
850 .explain(&sql)
851 .map_err(|error| Failed::from_engine(&error))?;
852 let rows: Vec<Vec<Json>> = plan.iter().map(|line| vec![json::text(line)]).collect();
853 let columns = vec![Column {
854 name: "plan".to_string(),
855 kind: "text".to_string(),
856 }];
857 Ok(Outcome {
858 command: "explain".to_string(),
859 text: plan.join("\n"),
860 total: rows.len(),
861 rows,
862 columns,
863 more: false,
864 changes: 0,
865 last_insert_rowid: 0,
866 elapsed_ms: 0.0,
867 extra: Vec::new(),
868 })
869}
870
871fn dot(context: &mut Context, command: &str, line: &str) -> Result<Outcome, Failed> {
877 let guarded = std::mem::replace(&mut context.shell().safe, false);
887 let printed = context.collect_output(line);
888 context.shell().safe = guarded;
889 if let Some(failed) = take_failure(context, &printed) {
890 return Err(failed);
891 }
892 Ok(Outcome::said(command, printed.trim_end()))
893}
894
895pub fn dump(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
897 let mut line = String::from(".dump");
898 if arguments.flag("data_only") {
899 line.push_str(" --data-only");
900 }
901 if let Some(objects) = arguments.text("objects") {
902 line.push(' ');
903 line.push_str(objects);
904 }
905 dot(context, "dump", &line)
906}
907
908pub fn import(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
910 let file = arguments.required_text("file")?.to_string();
911 let table_name = arguments.required_text("table")?.to_string();
912 let confined = context.confine(&file)?;
913 let mut line = String::from(".import");
914 match arguments.text("format").unwrap_or("csv") {
915 "csv" => line.push_str(" --csv"),
916 "ascii" => line.push_str(" --ascii"),
917 "tabs" => line.push_str(" --colsep \"\t\""),
918 other => {
919 return Err(Failed::misuse(format!(
920 "'{other}' is not a format this reads. Use csv, tabs or ascii."
921 )))
922 }
923 }
924 if let Some(skip) = arguments.integer("skip") {
925 line.push_str(&format!(" --skip {skip}"));
926 }
927 line.push_str(&format!(
928 " \"{}\" \"{table_name}\"",
929 confined.to_string_lossy()
930 ));
931 let before_changes = context
940 .shell()
941 .connection()
942 .total_changes()
943 .map_err(|error| Failed::from_engine(&error))?;
944 let before_rows = row_count(context, &table_name);
945 let mut produced = dot(context, "import", &line)?;
946 let after_changes = context
947 .shell()
948 .connection()
949 .total_changes()
950 .map_err(|error| Failed::from_engine(&error))?;
951 produced.changes = after_changes - before_changes;
952 if produced.changes == 0 {
953 produced.changes = row_count(context, &table_name).saturating_sub(before_rows);
954 }
955 if produced.text.is_empty() {
956 produced.text = format!("imported {} rows into {table_name}", produced.changes);
957 }
958 Ok(produced)
959}
960
961fn row_count(context: &mut Context, table: &str) -> i64 {
966 let sql = format!("SELECT count(*) FROM \"{}\"", table.replace('"', "\"\""));
967 context
968 .shell()
969 .scalar(&sql)
970 .and_then(|text| text.parse::<i64>().ok())
971 .unwrap_or(0)
972}
973
974pub fn export(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
976 let sql = match (arguments.text("sql"), arguments.text("table")) {
977 (Some(_), Some(_)) => {
978 return Err(Failed::misuse(
979 "export accepts either 'sql' or 'table', not both.",
980 ))
981 }
982 (Some(sql), None) => sql.to_string(),
983 (None, Some(name)) => format!("SELECT * FROM {}", quoted(name)),
984 (None, None) => return Err(Failed::misuse("export needs either 'sql' or 'table'.")),
985 };
986 let format = arguments.text("format").unwrap_or("csv").to_string();
987 let mode = match format.as_str() {
988 "csv" | "json" | "tabs" | "markdown" | "insert" | "quote" | "line" | "html" => format,
989 other => {
990 return Err(Failed::misuse(format!(
991 "'{other}' is not an export format. Use csv, json, tabs, markdown, insert, \
992 quote, line or html."
993 )))
994 }
995 };
996 context.refuse_if_it_writes(&sql)?;
997 let destination = match arguments.text("out") {
1014 None => None,
1015 Some(out) => Some(context.confine(out)?),
1016 };
1017 if let Some(path) = destination.as_ref() {
1018 let named = path.to_string_lossy().into_owned();
1019 context
1020 .shell()
1021 .redirect(Some(&named), true)
1022 .map_err(|message| {
1023 Failed::said(Status::Io, format!("cannot open \"{named}\": {message}"))
1024 })?;
1025 }
1026 let script = format!(".mode {mode}\n.headers on\n{sql};");
1027 let printed = context.collect_output(&script);
1028 let rows = context.shell().rows_since_redirect;
1029 if destination.is_some() {
1034 let _ = context.shell().redirect(None, false);
1035 }
1036 if let Some(failed) = take_failure(context, &printed) {
1037 return Err(failed);
1038 }
1039 let Some(path) = destination else {
1040 return Ok(Outcome::said("export", printed.trim_end()));
1041 };
1042 wrote_a_file(&path, rows)
1043}
1044
1045fn wrote_a_file(path: &std::path::Path, rows: usize) -> Result<Outcome, Failed> {
1059 let named = path.to_string_lossy().into_owned();
1060 let bytes = std::fs::metadata(path)
1061 .map(|found| found.len())
1062 .unwrap_or(0);
1063 let mut produced = Outcome::said(
1064 "export",
1065 format!(
1066 "wrote {rows} row{} ({bytes} bytes) to {named}",
1067 if rows == 1 { "" } else { "s" }
1068 ),
1069 );
1070 produced.total = rows;
1071 Ok(produced
1072 .with("wrote", json::text(&named))
1073 .with("bytes", Json::Int(bytes as i64)))
1074}
1075
1076pub fn backup(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1078 let file = arguments.required_text("file")?.to_string();
1079 let confined = context.confine(&file)?;
1080 let named = confined.to_string_lossy().into_owned();
1081 context
1082 .shell()
1083 .backup_to(&named)
1084 .map_err(|message| Failed::said(Status::Io, message))?;
1085 Ok(Outcome::said("backup", format!("wrote {named}")).with("wrote", json::text(&named)))
1086}
1087
1088pub fn encrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1094 let Some(key) = crate::keys::configured() else {
1095 return Err(Failed::said(
1096 Status::InvalidState,
1097 "encrypt needs a key for the copy: pass --key-file or set INILLUCENT_KEY",
1098 ));
1099 };
1100 let named = copy_target(context, arguments)?;
1101 context
1102 .shell()
1103 .export_to(&named, Some(key))
1104 .map_err(|error| Failed::from_engine(&error))?;
1105 Ok(
1106 Outcome::said("encrypt", format!("wrote {named}, encrypted"))
1107 .with("wrote", json::text(&named))
1108 .with("encryption", json::text(inillucent_driver::CIPHER_NAME)),
1109 )
1110}
1111
1112pub fn decrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1114 if !context.shell().is_encrypted() {
1115 return Err(Failed::said(
1116 Status::InvalidState,
1117 "this database is not encrypted, so there is nothing to decrypt",
1118 ));
1119 }
1120 let named = copy_target(context, arguments)?;
1121 context
1122 .shell()
1123 .export_to(&named, None)
1124 .map_err(|error| Failed::from_engine(&error))?;
1125 Ok(
1126 Outcome::said("decrypt", format!("wrote {named}, not encrypted"))
1127 .with("wrote", json::text(&named))
1128 .with("encryption", json::text("none")),
1129 )
1130}
1131
1132pub fn rekey(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1137 let key = match arguments.text("new-key-file") {
1138 Some(path) => {
1139 let confined = context.confine(path)?;
1140 crate::keys::read_key_file(&confined.to_string_lossy())
1141 .map_err(|message| Failed::said(Status::InvalidState, message))?
1142 }
1143 None => match std::env::var(crate::keys::NEW_KEY_VARIABLE) {
1144 Ok(text) if !text.is_empty() => inillucent_driver::EncryptionKey::parse(&text),
1145 _ => {
1146 return Err(Failed::said(
1147 Status::InvalidState,
1148 "rekey needs the new key: pass --new-key-file or set INILLUCENT_NEW_KEY",
1149 ))
1150 }
1151 },
1152 };
1153 context
1154 .shell()
1155 .rekey(key)
1156 .map_err(|error| Failed::from_engine(&error))?;
1157 Ok(Outcome::said("rekey", "the key was changed".to_string()))
1158}
1159
1160fn copy_target(context: &Context, arguments: &Arguments) -> Result<String, Failed> {
1165 let file = arguments.required_text("file")?.to_string();
1166 let confined = context.confine(&file)?;
1167 Ok(confined.to_string_lossy().into_owned())
1168}
1169
1170pub fn restore(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1172 let file = arguments.required_text("file")?.to_string();
1173 let confined = context.confine(&file)?;
1174 if !confined.is_file() {
1182 return Err(Failed::said(
1183 Status::NotFound,
1184 format!(
1185 "{}: there is no such backup file to restore from",
1186 confined.to_string_lossy()
1187 ),
1188 ));
1189 }
1190 dot(
1191 context,
1192 "restore",
1193 &format!(".restore \"{}\"", confined.to_string_lossy()),
1194 )
1195}
1196
1197pub fn checkpoint(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1199 listing(context, "checkpoint", "PRAGMA wal_checkpoint")
1200}
1201
1202pub fn integrity_check(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1215 let produced = listing(context, "integrity-check", "PRAGMA integrity_check")?;
1216 if let Some(damage) = first_damage(&produced) {
1217 return Err(Failed::said(Status::Corrupt, damage));
1218 }
1219 Ok(produced)
1220}
1221
1222fn first_damage(produced: &Outcome) -> Option<String> {
1232 let said: Vec<String> = produced
1233 .rows
1234 .iter()
1235 .flatten()
1236 .map(|value| match value {
1237 Json::Text(text) => text.clone(),
1238 other => format!("{other:?}"),
1239 })
1240 .collect();
1241 if said.is_empty() {
1242 return Some(
1243 "PRAGMA integrity_check returned no rows at all, so this database's soundness is \
1244 unknown rather than confirmed"
1245 .to_string(),
1246 );
1247 }
1248 if said.iter().all(|line| line.trim() == "ok") {
1249 return None;
1250 }
1251 Some(said.join("; "))
1252}
1253
1254pub fn analyze(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1256 let sql = match arguments.text("table") {
1257 Some(name) => format!("ANALYZE {}", quoted(name)),
1258 None => "ANALYZE".to_string(),
1259 };
1260 context
1261 .shell()
1262 .execute(&sql)
1263 .map_err(|message| Failed::said(Status::Syntax, message))?;
1264 Ok(Outcome::said("analyze", "ok. sqlite_stat1 is up to date."))
1265}
1266
1267pub fn stats(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1269 let cache = context.shell().cache_stats();
1270 let pool = context.shell().pool_bytes();
1271 let pages = context
1272 .shell()
1273 .scalar("PRAGMA page_count")
1274 .unwrap_or_default();
1275 let size = context
1276 .shell()
1277 .scalar("PRAGMA page_size")
1278 .unwrap_or_default();
1279 let free = context
1280 .shell()
1281 .scalar("PRAGMA freelist_count")
1282 .unwrap_or_default();
1283 let text = format!(
1284 "pool bytes: {pool}\npage size: {size}\npage count: {pages}\n\
1285 free pages: {free}\ncache hits: {}\ncache misses: {}",
1286 cache.hits, cache.misses
1287 );
1288 Ok(Outcome::said("stats", text)
1289 .with("pool_bytes", Json::Int(pool as i64))
1290 .with("page_size", Json::Int(size.parse::<i64>().unwrap_or(0)))
1291 .with("page_count", Json::Int(pages.parse::<i64>().unwrap_or(0)))
1292 .with("free_pages", Json::Int(free.parse::<i64>().unwrap_or(0)))
1293 .with("cache_hits", Json::Int(cache.hits as i64))
1294 .with("cache_misses", Json::Int(cache.misses as i64)))
1295}
1296
1297fn neighbours_asked_for(arguments: &Arguments) -> Result<i64, Failed> {
1307 let k = arguments.integer("k").unwrap_or(10);
1308 if k < 1 {
1309 return Err(Failed::misuse(
1310 "'k' has to be one or more: it is how many rows to return.",
1311 ));
1312 }
1313 Ok(k)
1314}
1315
1316pub fn search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1323 let query_text = arguments.required_text("query")?.to_string();
1324 let name = arguments.required_text("table")?.to_string();
1325 let k = neighbours_asked_for(arguments)?;
1326 let reranked = match arguments.flag("rerank") {
1329 true => format!(" AND question = {} AND k = {k}", quoted_text(&query_text)),
1330 false => String::new(),
1331 };
1332 let sql = format!(
1333 "SELECT rowid, * FROM {0} WHERE {0} MATCH {1}{reranked} ORDER BY rank LIMIT {k}",
1334 quoted(&name),
1335 quoted_text(&query_text)
1336 );
1337 let mut produced = produce(context, "search", &sql, &[], 0)?;
1338 produced.command = "search".to_string();
1339 Ok(produced.with("query", json::text(&query_text)))
1340}
1341
1342pub fn vector_search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1344 let name = arguments.required_text("table")?.to_string();
1345 let column = arguments.required_text("column")?.to_string();
1346 let numbers = arguments.values("vector");
1347 if numbers.is_empty() {
1348 return Err(Failed::misuse(
1349 "'vector' has to be an array of numbers, one per dimension.",
1350 ));
1351 }
1352 let mut blob = String::from("x'");
1353 for value in &numbers {
1354 let Some(number) = value.integer().map(|whole| whole as f64).or(match value {
1355 Json::Real(real) => Some(*real),
1356 _ => None,
1357 }) else {
1358 return Err(Failed::misuse(
1359 "every element of 'vector' has to be a number.",
1360 ));
1361 };
1362 for byte in (number as f32).to_bits().to_le_bytes() {
1363 blob.push_str(&format!("{byte:02x}"));
1364 }
1365 }
1366 blob.push('\'');
1367 let k = neighbours_asked_for(arguments)?;
1368 let measure = arguments.text("measure").unwrap_or("cos");
1369 let function = match measure {
1370 "cos" => "vector_distance_cos",
1371 "l2" => "vector_distance_l2",
1372 "dot" => "vector_dot",
1373 other => {
1374 return Err(Failed::misuse(format!(
1375 "'{other}' is not a measure. Use cos, l2 or dot."
1376 )))
1377 }
1378 };
1379 let shape = searched_table_shape(context, &name);
1380 let rowid = if shape.integer_key { "" } else { "rowid, " };
1384 let sql = format!(
1385 "SELECT {rowid}*, {function}({1}, {blob}) AS distance FROM {0} \
1386 WHERE {1} IS NOT NULL ORDER BY {function}({1}, {blob}) LIMIT {k}",
1387 quoted(&name),
1388 quoted(&column)
1389 );
1390 let mut produced = produce(context, "vector-search", &sql, &[], 0)?;
1391 let offset = usize::from(!shape.integer_key);
1392 let positions: Vec<usize> = shape.vectors.iter().map(|nth| nth + offset).collect();
1393 for row in &mut produced.rows {
1394 for &nth in &positions {
1395 if let Some(cell) = row.get_mut(nth) {
1396 if let Some(numbers) = vector_numbers(cell) {
1397 *cell = numbers;
1398 }
1399 }
1400 }
1401 }
1402 if !positions.is_empty() {
1403 let names: Vec<String> = produced.columns.iter().map(|c| c.name.clone()).collect();
1404 produced.columns = columns_from(&names, &produced.rows);
1405 produced.text = table(&produced.columns, &produced.rows, &context.null);
1406 }
1407 Ok(produced)
1408}
1409
1410struct SearchedTable {
1412 integer_key: bool,
1414 vectors: Vec<usize>,
1416}
1417
1418fn searched_table_shape(context: &mut Context, name: &str) -> SearchedTable {
1427 let info = context
1428 .shell()
1429 .collect(&format!("PRAGMA table_info({})", quoted(name)))
1430 .map(|(_, rows)| rows)
1431 .unwrap_or_default();
1432 let text_of = |value: Option<&Value<'static>>| match value {
1433 Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).to_ascii_uppercase(),
1434 _ => String::new(),
1435 };
1436 let key_of = |row: &Vec<Value<'static>>| match row.get(5) {
1437 Some(Value::Integer(key)) => *key,
1438 _ => 0,
1439 };
1440 let keys: Vec<&Vec<Value<'static>>> = info.iter().filter(|row| key_of(row) > 0).collect();
1441 let integer_key = matches!(keys.as_slice(), [only] if text_of(only.get(2)) == "INTEGER");
1442 let vectors = info
1443 .iter()
1444 .enumerate()
1445 .filter(|(_, row)| text_of(row.get(2)).starts_with("VECTOR"))
1446 .map(|(nth, _)| nth)
1447 .collect();
1448 SearchedTable {
1449 integer_key,
1450 vectors,
1451 }
1452}
1453
1454fn vector_numbers(cell: &Json) -> Option<Json> {
1462 let hex = cell.get("blob").and_then(Json::text)?;
1463 let bytes: Vec<u8> = hex
1464 .as_bytes()
1465 .chunks(2)
1466 .map(|pair| {
1467 std::str::from_utf8(pair)
1468 .ok()
1469 .and_then(|digits| u8::from_str_radix(digits, 16).ok())
1470 })
1471 .collect::<Option<Vec<u8>>>()?;
1472 if !bytes.len().is_multiple_of(4) {
1473 return None;
1474 }
1475 let numbers = bytes
1476 .chunks_exact(4)
1477 .map(|word| {
1478 let mut four = [0u8; 4];
1479 four.copy_from_slice(word);
1480 Json::Real(f64::from(f32::from_le_bytes(four)))
1481 })
1482 .collect();
1483 Some(Json::Array(numbers))
1484}
1485
1486pub fn capabilities(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1488 let wanted = arguments.text("name");
1489 let rows: Vec<Vec<Json>> = inillucent_driver::CAPABILITIES
1490 .iter()
1491 .filter(|entry| wanted.is_none_or(|name| entry.name == name))
1492 .map(|entry| {
1493 vec![
1494 json::text(entry.name),
1495 json::text(support_name(entry.support)),
1496 json::text(entry.note),
1497 ]
1498 })
1499 .collect();
1500 if rows.is_empty() {
1501 return Err(Failed::said(
1502 Status::NotFound,
1503 format!(
1504 "no capability named \"{}\". An unknown name means no, never yes: a capability \
1505 that was never declared was never checked.",
1506 wanted.unwrap_or_default()
1507 ),
1508 ));
1509 }
1510 let names = vec![
1511 "capability".to_string(),
1512 "support".to_string(),
1513 "note".to_string(),
1514 ];
1515 let columns = columns_from(&names, &rows);
1516 let text = wrapped_notes(&rows);
1522 Ok(Outcome {
1523 command: "capabilities".to_string(),
1524 total: rows.len(),
1525 rows,
1526 columns,
1527 more: false,
1528 changes: 0,
1529 last_insert_rowid: 0,
1530 elapsed_ms: 0.0,
1531 text,
1532 extra: Vec::new(),
1533 })
1534}
1535
1536fn wrapped_notes(rows: &[Vec<Json>]) -> String {
1540 let mut lines = Vec::with_capacity(rows.len() * 3);
1541 for row in rows {
1542 let name = row.first().and_then(Json::text).unwrap_or_default();
1543 let support = row.get(1).and_then(Json::text).unwrap_or_default();
1544 let note = row.get(2).and_then(Json::text).unwrap_or_default();
1545 lines.push(format!("{name:<22} {support}"));
1546 for line in wrap(note, 74) {
1547 lines.push(format!(" {line}"));
1548 }
1549 }
1550 lines.join(
1551 "
1552",
1553 )
1554}
1555
1556fn wrap(text: &str, width: usize) -> Vec<String> {
1565 let mut lines = Vec::new();
1566 let mut current = String::new();
1567 for word in text.split_whitespace() {
1568 if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
1569 lines.push(std::mem::take(&mut current));
1570 }
1571 if !current.is_empty() {
1572 current.push(' ');
1573 }
1574 current.push_str(word);
1575 }
1576 if !current.is_empty() {
1577 lines.push(current);
1578 }
1579 lines
1580}
1581
1582fn support_name(support: Support) -> &'static str {
1586 match support {
1587 Support::Yes => "yes",
1588 Support::Partial => "partial",
1589 Support::No => "no",
1590 }
1591}
1592
1593pub fn functions(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1595 let mut sql = String::from("PRAGMA function_list");
1596 let produced = listing(context, "functions", &sql);
1597 let mut produced = produced?;
1602 if let Some(pattern) = arguments.text("pattern") {
1603 produced.rows.retain(|row| {
1604 row.first()
1605 .and_then(Json::text)
1606 .is_some_and(|name| like_matches(pattern, name))
1607 });
1608 produced.total = produced.rows.len();
1609 produced.text = table(&produced.columns, &produced.rows, &context.null);
1610 }
1611 sql.clear();
1612 Ok(produced)
1613}
1614
1615fn like_matches(pattern: &str, name: &str) -> bool {
1626 let pattern: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
1627 let name: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
1628 let (mut p, mut n) = (0usize, 0usize);
1632 let mut star: Option<(usize, usize)> = None;
1633 while n < name.len() {
1634 match pattern.get(p) {
1635 Some('%') => {
1636 star = Some((p, n));
1637 p += 1;
1638 }
1639 Some(&c) if c == '_' || name.get(n) == Some(&c) => {
1640 p += 1;
1641 n += 1;
1642 }
1643 _ => match star {
1644 Some((star_p, star_n)) => {
1645 p = star_p + 1;
1646 n = star_n + 1;
1647 star = Some((star_p, star_n + 1));
1648 }
1649 None => return false,
1650 },
1651 }
1652 }
1653 pattern
1654 .get(p..)
1655 .is_some_and(|rest| rest.iter().all(|&c| c == '%'))
1656}
1657
1658pub fn migrate(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1670 let destination = arguments.required_text("destination")?.to_string();
1671 let source = resolve_source(context, arguments.text("source"))?;
1672 let kind = arguments
1673 .text("kind")
1674 .map(str::to_string)
1675 .unwrap_or_else(|| kind_of_source(&source));
1676 if kind == "postgres" || kind == "mysql" {
1677 return migrate_remote(context, &source, &destination, arguments);
1678 }
1679
1680 let from = context.confine(&source)?;
1681 let to = context.confine(&destination)?;
1682 if !from.exists() {
1683 return Err(Failed::said(
1684 Status::NotFound,
1685 format!("there is no \"{source}\" to migrate from."),
1686 ));
1687 }
1688 if to.exists() {
1689 return Err(Failed::said(
1690 Status::InvalidState,
1691 format!("\"{destination}\" already exists. This tool never overwrites."),
1692 ));
1693 }
1694 match kind.as_str() {
1695 "sqlite" => migrate_sqlite_file(&from, &to),
1696 "index" => Err(Failed::unsupported(
1697 "migrate --kind index",
1698 "the retrieval-index migration runs in inillucent-migrate, which links the retrieval \
1699 engine. Run: inillucent-migrate <source-index-dir> <destination.db>",
1700 )),
1701 other => Err(Failed::misuse(format!(
1702 "'{other}' is not a migration kind. Use sqlite, postgres, mysql or index."
1703 ))),
1704 }
1705}
1706
1707const SOURCE_URL_VARIABLE: &str = "INILLUCENT_SOURCE_URL";
1709
1710fn resolve_source(context: &Context, argument: Option<&str>) -> Result<String, Failed> {
1732 if let Some(source) = argument {
1733 if source != "-" {
1734 return Ok(source.to_string());
1735 }
1736 }
1737 if let Ok(held) = std::env::var(SOURCE_URL_VARIABLE) {
1738 if !held.trim().is_empty() {
1739 return Ok(held);
1740 }
1741 }
1742 if argument == Some("-") {
1743 if context.confined() {
1744 return Err(Failed::said(
1745 Status::InvalidState,
1746 "this surface is confined to a directory with --root, and '-' reads the source \
1747 from standard input, which such a surface does not have to itself. Set \
1748 INILLUCENT_SOURCE_URL instead.",
1749 ));
1750 }
1751 let mut line = String::new();
1752 std::io::stdin()
1753 .read_line(&mut line)
1754 .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
1755 let line = line.trim_end_matches(['\r', '\n']).to_string();
1756 if line.is_empty() {
1757 return Err(Failed::misuse(
1758 "standard input held no source. Write the file path or the connection URL on one \
1759 line.",
1760 ));
1761 }
1762 return Ok(line);
1763 }
1764 Err(Failed::misuse(format!(
1765 "migrate needs a source: a database file, or a postgres:// or mysql:// URL. Pass it as \
1766 the first argument, set {SOURCE_URL_VARIABLE}, or pass '-' to read one line from \
1767 standard input."
1768 )))
1769}
1770
1771fn kind_of_source(source: &str) -> String {
1775 match inillucent_remote::ConnectionUrl::parse(source) {
1776 Ok(url) => url.scheme.name().to_string(),
1777 Err(_) => "sqlite".to_string(),
1778 }
1779}
1780
1781fn migrate_remote(
1795 context: &mut Context,
1796 source: &str,
1797 destination: &str,
1798 arguments: &Arguments,
1799) -> Result<Outcome, Failed> {
1800 if context.confined() {
1801 return Err(Failed::said(
1802 Status::InvalidState,
1803 "this surface is confined to a directory with --root, and a migration from a server \
1804 reaches a host and a port rather than a path. Run it from an unconfined command \
1805 line.",
1806 ));
1807 }
1808 let url = inillucent_remote::ConnectionUrl::parse(source)
1809 .map_err(|error| Failed::misuse(error.detail().unwrap_or_else(|| error.message())))?;
1810 let to = context.confine(destination)?;
1811 if to.exists() {
1812 return Err(Failed::said(
1813 Status::InvalidState,
1814 format!("\"{destination}\" already exists. This tool never overwrites."),
1815 ));
1816 }
1817 let mut plan = inillucent_remote::Plan::new(url, &to);
1818 plan.limits = Some(context.limits());
1823 if let Some(batch) = arguments.integer("batch") {
1824 plan.batch = (batch.max(1)) as u64;
1825 }
1826 plan.insecure_plaintext = arguments.flag("insecure-plaintext");
1827 plan.transport().map_err(|error| {
1831 Failed::said(
1832 Status::InvalidState,
1833 error.detail().unwrap_or_else(|| error.message()),
1834 )
1835 })?;
1836 let report = inillucent_remote::migrate::migrate(&plan).map_err(|error| {
1837 Failed::said(
1838 Status::Io,
1839 error.detail().unwrap_or_else(|| error.message()),
1840 )
1841 })?;
1842
1843 let checks: Vec<json::Json> = report
1844 .checks
1845 .iter()
1846 .map(|check| {
1847 json::object(vec![
1848 ("name", json::text(&check.name)),
1849 ("passed", json::Json::Bool(check.passed)),
1850 ("detail", json::text(&check.detail)),
1851 ])
1852 })
1853 .collect();
1854 let tables: Vec<json::Json> = report
1855 .tables
1856 .iter()
1857 .map(|table| {
1858 json::object(vec![
1859 ("source", json::text(&table.source)),
1860 ("destination", json::text(&table.target)),
1861 ("rows", json::Json::Int(table.rows as i64)),
1862 ("digest", json::text(&table.digest)),
1863 ])
1864 })
1865 .collect();
1866 let not_carried: Vec<json::Json> = report
1867 .not_carried
1868 .iter()
1869 .map(|(kind, name)| {
1870 json::object(vec![("kind", json::text(kind)), ("name", json::text(name))])
1871 })
1872 .collect();
1873
1874 let mut text = format!(
1879 "{} -> {}\n{}, {} tables, {} rows\ntransport: {}\n",
1880 report.source,
1881 to.display(),
1882 report.server,
1883 report.tables.len(),
1884 report.rows(),
1885 report.transport
1886 );
1887 for check in &report.checks {
1888 text.push_str(&format!(" {}\n", check.line()));
1889 }
1890 if !report.passed() {
1891 text.push_str(&format!(
1892 "verification failed; nothing was published. The staging file is at {}",
1893 report.staged.display()
1894 ));
1895 return Err(Failed::said(Status::Io, text));
1896 }
1897 text.push_str(&format!("published: {}", to.display()));
1898
1899 Ok(Outcome::said("migrate", text)
1900 .with("destination", json::text(to.to_string_lossy()))
1901 .with("transport", json::text(&report.transport))
1904 .with("source", json::text(&report.source))
1907 .with("server", json::text(&report.server))
1908 .with("rows", json::Json::Int(report.rows() as i64))
1909 .with("tables", json::Json::Array(tables))
1910 .with("checks", json::Json::Array(checks))
1911 .with("notCarried", json::Json::Array(not_carried)))
1912}
1913
1914fn migrate_sqlite_file(from: &std::path::Path, to: &std::path::Path) -> Result<Outcome, Failed> {
1939 let report = inillucent_migrate::sqlite::migrate(from, to)
1942 .map_err(|error| Failed::from_engine(&error))?;
1943 let failures: Vec<String> = report
1944 .failures()
1945 .iter()
1946 .map(|check| format!("{}: {}", check.name, check.detail))
1947 .collect();
1948 let checks = Json::Array(
1949 report
1950 .checks
1951 .iter()
1952 .map(|check| {
1953 json::object(vec![
1954 ("name", json::text(&check.name)),
1955 ("passed", Json::Bool(check.passed)),
1956 ("detail", json::text(&check.detail)),
1957 ])
1958 })
1959 .collect(),
1960 );
1961 if !report.passed() {
1965 return Err(Failed::said(
1966 Status::Corrupt,
1967 format!(
1968 "{} was not published: {}",
1969 to.display(),
1970 failures.join("; ")
1971 ),
1972 ));
1973 }
1974 Ok(Outcome::said(
1975 "migrate",
1976 format!("imported {} into {}", from.display(), to.display()),
1977 )
1978 .with("destination", json::text(to.to_string_lossy()))
1979 .with("checks", checks))
1980}
1981
1982pub fn version(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1984 let printed = context.collect_output(".version");
1985 context.shell().failed = false;
1986 context.shell().first_error = None;
1987 let text = format!(
1988 "{}
1989inillucent-cli {}
1990{}",
1991 printed.trim_end(),
1992 env!("CARGO_PKG_VERSION"),
1993 inillucent_driver::version()
1994 );
1995 Ok(Outcome::said("version", text)
1996 .with("cli", json::text(env!("CARGO_PKG_VERSION")))
1997 .with("driver", json::text(inillucent_driver::version())))
1998}
1999
2000pub fn help(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
2002 match arguments.text("topic") {
2003 None => {
2004 let rows: Vec<Vec<Json>> = super::COMMANDS
2005 .iter()
2006 .map(|command| vec![json::text(command.name), json::text(command.summary)])
2007 .collect();
2008 let names = vec!["command".to_string(), "what it does".to_string()];
2009 let columns = columns_from(&names, &rows);
2010 let text = table(&columns, &rows, "");
2011 Ok(Outcome {
2012 command: "help".to_string(),
2013 total: rows.len(),
2014 rows,
2015 columns,
2016 more: false,
2017 changes: 0,
2018 last_insert_rowid: 0,
2019 elapsed_ms: 0.0,
2020 text,
2021 extra: Vec::new(),
2022 })
2023 }
2024 Some(topic) => {
2025 let Some(command) = super::find(topic) else {
2026 return Err(Failed::said(
2027 Status::NotFound,
2028 format!("there is no '{topic}' command. Run 'inillucent help' for the list."),
2029 ));
2030 };
2031 let mut text = format!(
2032 "{}\n\n{}\n\n{}",
2033 command.usage(),
2034 command.summary,
2035 command.detail
2036 );
2037 if !command.params.is_empty() {
2038 text.push_str("\n\nParameters:");
2039 for param in command.params {
2040 text.push_str(&format!(
2041 "\n {:<12} {}{}",
2042 param.name,
2043 if param.required { "(required) " } else { "" },
2044 param.description
2045 ));
2046 }
2047 }
2048 Ok(Outcome::said("help", text))
2049 }
2050 }
2051}
2052
2053fn front_end_only(name: &'static str) -> Failed {
2062 Failed::misuse(format!(
2063 "'{name}' is run by the inillucent binary itself and cannot be dispatched here."
2064 ))
2065}
2066
2067pub fn shell_placeholder(
2069 _context: &mut Context,
2070 _arguments: &Arguments,
2071) -> Result<Outcome, Failed> {
2072 Err(front_end_only("shell"))
2073}
2074
2075pub fn mcp_placeholder(_context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
2077 Err(front_end_only("mcp"))
2078}
2079
2080#[cfg(test)]
2081mod source_tests {
2082 use super::*;
2083 use crate::shell::Shell;
2084
2085 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2099 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2100 LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
2101 }
2102
2103 fn context(root: Option<std::path::PathBuf>) -> Context {
2107 Context::for_test(
2108 Shell::open(":memory:").expect("a memory database opens"),
2109 root,
2110 )
2111 }
2112
2113 #[test]
2115 fn an_argument_is_the_source() {
2116 let context = context(None);
2117 let held = resolve_source(&context, Some("postgres://user@host/db"))
2118 .expect("the argument is accepted");
2119 assert_eq!(held, "postgres://user@host/db");
2120 }
2121
2122 #[test]
2127 fn the_environment_supplies_a_source_that_was_not_an_argument() {
2128 let _held = env_guard();
2129 let context = context(None);
2130 std::env::set_var(SOURCE_URL_VARIABLE, "postgres://user:secret@host/db");
2131 let held = resolve_source(&context, None).expect("the variable is read");
2132 std::env::remove_var(SOURCE_URL_VARIABLE);
2133 assert_eq!(held, "postgres://user:secret@host/db");
2134 }
2135
2136 #[test]
2138 fn no_source_anywhere_is_refused_by_name() {
2139 let _held = env_guard();
2140 let context = context(None);
2141 std::env::remove_var(SOURCE_URL_VARIABLE);
2142 let error = resolve_source(&context, None).expect_err("there is no source");
2143 let said = format!("{error:?}");
2144 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2145 assert!(said.contains("standard input"), "{said}");
2146 }
2147
2148 #[test]
2154 fn a_confined_surface_refuses_to_read_standard_input() {
2155 let _held = env_guard();
2156 let context = context(Some(std::env::temp_dir()));
2157 std::env::remove_var(SOURCE_URL_VARIABLE);
2158 let error = resolve_source(&context, Some("-")).expect_err("a confined surface refuses");
2159 let said = format!("{error:?}");
2160 assert!(said.contains("--root"), "{said}");
2161 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2162 }
2163
2164 #[test]
2166 fn export_refuses_table_and_sql_together() {
2167 let mut arguments = Arguments::default();
2168 arguments.set("table", crate::json::text("expected"));
2169 arguments.set("sql", crate::json::text("SELECT 'other' AS v"));
2170 let failure = export(&mut context(None), &arguments)
2171 .expect_err("export must require one data source");
2172 assert!(failure.message.contains("not both"), "{}", failure.message);
2173 }
2174
2175 #[test]
2180 fn the_environment_wins_over_reading_standard_input() {
2181 let _held = env_guard();
2182 let context = context(None);
2183 std::env::set_var(SOURCE_URL_VARIABLE, "mysql://user@host/db");
2184 let held = resolve_source(&context, Some("-")).expect("the variable is read");
2185 std::env::remove_var(SOURCE_URL_VARIABLE);
2186 assert_eq!(held, "mysql://user@host/db");
2187 }
2188}