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 {
474 context
475 .shell()
476 .connection()
477 .execute_batch("BEGIN")
478 .map_err(|error| Failed::from_engine(&error))?;
479 }
480 let ran = context.shell().connection().execute_batch(&sql);
487 if let Err(error) = ran {
488 let failed = Failed::from_engine(&error);
489 if !joined {
490 if let Err(second) = context.shell().execute("ROLLBACK") {
496 return Err(Failed {
497 message: format!("{} (and the rollback failed: {second})", failed.message),
498 ..failed
499 });
500 }
501 }
502 return Err(failed);
503 }
504 if !joined {
505 context
506 .shell()
507 .connection()
508 .execute_batch("COMMIT")
509 .map_err(|error| Failed::from_engine(&error))?;
510 }
511 let after = context
512 .shell()
513 .connection()
514 .total_changes()
515 .map_err(|error| Failed::from_engine(&error))?;
516 let changes = after - before;
517 let mut produced = Outcome::said(
518 "batch",
519 format!(
520 "ok. {changes} row{} changed.",
521 if changes == 1 { "" } else { "s" }
522 ),
523 );
524 produced.changes = changes;
525 produced.extra.push((
526 "transaction".to_string(),
527 Json::Text(
528 if joined {
529 "joined the open transaction; not committed"
530 } else {
531 "committed"
532 }
533 .to_string(),
534 ),
535 ));
536 Ok(produced)
537}
538
539fn take_failure(context: &mut Context, printed: &str) -> Option<Failed> {
551 let shell = context.shell();
552 let failed = std::mem::replace(&mut shell.failed, false);
553 let error = shell.first_error.take();
554 if !failed {
555 return None;
556 }
557 let message = printed.trim_end().to_string();
558 Some(match error {
559 Some(error) => Failed {
560 message,
561 ..Failed::from_engine(&error)
562 },
563 None => Failed::said(Status::Syntax, message),
564 })
565}
566
567pub fn run_input(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
569 let input = arguments.required_text("input")?.to_string();
570 if context.readonly() {
571 for line in input.lines() {
572 let trimmed = line.trim();
573 if trimmed.is_empty() || trimmed.starts_with('.') {
574 continue;
575 }
576 context.refuse_if_it_writes(trimmed)?;
577 }
578 }
579 let printed = context.collect_output(&input);
580 if let Some(failed) = take_failure(context, &printed) {
589 return Err(failed);
590 }
591 Ok(Outcome::said("run", printed.trim_end()))
592}
593
594pub fn create(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
596 let path = arguments.required_text("path")?.to_string();
597 let confined = context.confine(&path)?;
598 if confined.exists() {
599 return Err(Failed::said(
600 Status::InvalidState,
601 format!("\"{path}\" already exists. Open it instead of creating it."),
602 ));
603 }
604 let named = confined.to_string_lossy().into_owned();
605 context.use_database(&named)?;
606 context
610 .shell()
611 .execute("PRAGMA user_version = 0")
612 .map_err(|message| Failed::said(Status::Io, message))?;
613 Ok(Outcome::said("create", format!("created {named}")).with("path", json::text(&named)))
614}
615
616fn listing(context: &mut Context, command: &str, sql: &str) -> Result<Outcome, Failed> {
622 produce(context, command, sql, &[], 0)
623}
624
625pub fn tables(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
627 let mut sql = String::from(
628 "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') \
629 AND name NOT LIKE 'sqlite_%'",
630 );
631 if let Some(pattern) = arguments.text("pattern") {
632 sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
633 }
634 sql.push_str(" ORDER BY name");
635 listing(context, "tables", &sql)
636}
637
638pub fn indexes(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
648 let pattern = arguments.text("pattern").map(str::to_string);
649 let mut sql =
650 String::from("SELECT name, tbl_name AS \"table\" FROM sqlite_master WHERE type = 'index'");
651 if let Some(pattern) = &pattern {
652 sql.push_str(&format!(" AND name LIKE {}", quoted_text(pattern)));
653 }
654 context.refuse_if_it_writes(&sql)?;
655 let started = std::time::Instant::now();
656 let (names, mut rows) = context
657 .shell()
658 .collect(&sql)
659 .map_err(|failure| Failed::from_shell(&failure))?;
660 rows.extend(module_indexes(context, pattern.as_deref())?);
661 rows.sort_by(|left, right| {
662 let key = |row: &Vec<Value<'static>>| {
663 (
664 row.get(1).map(text_of_value).unwrap_or_default(),
665 row.first().map(text_of_value).unwrap_or_default(),
666 )
667 };
668 key(left).cmp(&key(right))
669 });
670 let elapsed = started.elapsed().as_secs_f64() * 1000.0;
671 Ok(rows_to_outcome(context, "indexes", names, rows, 0, elapsed))
672}
673
674fn module_indexes(
683 context: &mut Context,
684 pattern: Option<&str>,
685) -> Result<Vec<Vec<Value<'static>>>, Failed> {
686 let tables = context
687 .shell()
688 .column("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'");
689 let mut found = Vec::new();
690 for table in tables {
691 let listed = context
692 .shell()
693 .collect(&format!("PRAGMA index_list({})", quoted_text(&table)))
694 .map_err(|failure| Failed::from_shell(&failure))?
695 .1;
696 for row in listed {
697 if row.get(3).map(text_of_value).as_deref() != Some("v") {
698 continue;
699 }
700 let Some(name) = row.get(1).map(text_of_value) else {
701 continue;
702 };
703 if let Some(pattern) = pattern {
704 if !like(&name, pattern) {
705 continue;
706 }
707 }
708 let (Ok(named), Ok(owner)) = (
709 Value::owned_text(name.as_bytes()),
710 Value::owned_text(table.as_bytes()),
711 ) else {
712 continue;
713 };
714 found.push(vec![named, owner]);
715 }
716 }
717 Ok(found)
718}
719
720fn text_of_value(value: &Value<'static>) -> String {
724 match value {
725 Value::Text(text) => String::from_utf8_lossy(text.raw()).into_owned(),
726 _ => String::new(),
727 }
728}
729
730fn like(name: &str, pattern: &str) -> bool {
739 let folded = name.to_lowercase();
740 let wanted = pattern.to_lowercase();
741 let parts: Vec<&str> = wanted.split('%').collect();
742 let mut at = 0usize;
743 for (which, part) in parts.iter().enumerate() {
744 if part.is_empty() {
745 continue;
746 }
747 let Some(found) = folded.get(at..).and_then(|rest| rest.find(part)) else {
748 return false;
749 };
750 if which == 0 && !wanted.starts_with('%') && found != 0 {
751 return false;
752 }
753 at = at.saturating_add(found).saturating_add(part.len());
754 }
755 if !wanted.ends_with('%') {
756 if let Some(last) = parts.last() {
757 if !last.is_empty() && at != folded.len() {
758 return false;
759 }
760 }
761 }
762 true
763}
764
765pub fn databases(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
767 listing(context, "databases", "PRAGMA database_list")
768}
769
770pub fn schema(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
772 let mut line = String::from(".schema");
773 if arguments.flag("indent") {
774 line.push_str(" --indent");
775 }
776 if let Some(pattern) = arguments.text("pattern") {
777 line.push(' ');
778 line.push_str(pattern);
779 }
780 let printed = context.collect_output(&line);
781 context.shell().failed = false;
782 context.shell().first_error = None;
783 Ok(Outcome::said("schema", printed.trim_end()))
784}
785
786pub fn describe(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
802 let name = arguments.required_text("table")?.to_string();
803 let info = format!(
804 "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({})",
805 quoted_text(&name)
806 );
807 let mut produced = produce(context, "describe", &info, &[], 0)?;
808 if produced.rows.is_empty() {
809 return Err(Failed::said(
810 Status::NotFound,
811 format!("no such table: {name}"),
812 ));
813 }
814 let ddl = context
815 .shell()
816 .scalar(&format!(
817 "SELECT sql FROM sqlite_master WHERE name = {}",
818 quoted_text(&name)
819 ))
820 .unwrap_or_default();
821 let index_rows = context.shell().column(&format!(
822 "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = {} ORDER BY name",
823 quoted_text(&name)
824 ));
825 let count = context
826 .shell()
827 .scalar(&format!("SELECT count(*) FROM {}", quoted(&name)))
828 .unwrap_or_default();
829 let indexes: Vec<Json> = index_rows.iter().map(json::text).collect();
830 let drawn = table(&produced.columns, &produced.rows, &context.null);
831 produced.text = format!(
832 "{name}: {} column{}, {count} row{}\n\n{drawn}\n\nindexes: {}\n\n{ddl}",
833 produced.rows.len(),
834 if produced.rows.len() == 1 { "" } else { "s" },
835 if count == "1" { "" } else { "s" },
836 match index_rows.is_empty() {
837 true => "none".to_string(),
838 false => index_rows.join(", "),
839 }
840 );
841 Ok(produced
842 .with("table", json::text(&name))
843 .with("ddl", json::text(ddl))
844 .with("indexes", Json::Array(indexes))
845 .with(
846 "row_count_in_table",
847 Json::Int(count.parse::<i64>().unwrap_or(-1)),
848 ))
849}
850
851pub fn explain(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
853 let sql = arguments.required_text("sql")?.to_string();
854 let plan = context
855 .shell()
856 .connection()
857 .explain(&sql)
858 .map_err(|error| Failed::from_engine(&error))?;
859 let rows: Vec<Vec<Json>> = plan.iter().map(|line| vec![json::text(line)]).collect();
860 let columns = vec![Column {
861 name: "plan".to_string(),
862 kind: "text".to_string(),
863 }];
864 Ok(Outcome {
865 command: "explain".to_string(),
866 text: plan.join("\n"),
867 total: rows.len(),
868 rows,
869 columns,
870 more: false,
871 changes: 0,
872 last_insert_rowid: 0,
873 elapsed_ms: 0.0,
874 extra: Vec::new(),
875 })
876}
877
878fn dot(context: &mut Context, command: &str, line: &str) -> Result<Outcome, Failed> {
884 let guarded = std::mem::replace(&mut context.shell().safe, false);
894 let printed = context.collect_output(line);
895 context.shell().safe = guarded;
896 if let Some(failed) = take_failure(context, &printed) {
897 return Err(failed);
898 }
899 Ok(Outcome::said(command, printed.trim_end()))
900}
901
902pub fn dump(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
904 let mut line = String::from(".dump");
905 if arguments.flag("data_only") {
906 line.push_str(" --data-only");
907 }
908 if let Some(objects) = arguments.text("objects") {
909 line.push(' ');
910 line.push_str(objects);
911 }
912 dot(context, "dump", &line)
913}
914
915pub fn import(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
917 let file = arguments.required_text("file")?.to_string();
918 let table_name = arguments.required_text("table")?.to_string();
919 let confined = context.confine(&file)?;
920 let mut line = String::from(".import");
921 match arguments.text("format").unwrap_or("csv") {
922 "csv" => line.push_str(" --csv"),
923 "ascii" => line.push_str(" --ascii"),
924 "tabs" => line.push_str(" --colsep \"\t\""),
925 other => {
926 return Err(Failed::misuse(format!(
927 "'{other}' is not a format this reads. Use csv, tabs or ascii."
928 )))
929 }
930 }
931 if let Some(skip) = arguments.integer("skip") {
932 line.push_str(&format!(" --skip {skip}"));
933 }
934 line.push_str(&format!(
935 " \"{}\" \"{table_name}\"",
936 confined.to_string_lossy()
937 ));
938 let before_changes = context
947 .shell()
948 .connection()
949 .total_changes()
950 .map_err(|error| Failed::from_engine(&error))?;
951 let before_rows = row_count(context, &table_name);
952 let mut produced = dot(context, "import", &line)?;
953 let after_changes = context
954 .shell()
955 .connection()
956 .total_changes()
957 .map_err(|error| Failed::from_engine(&error))?;
958 produced.changes = after_changes - before_changes;
959 if produced.changes == 0 {
960 produced.changes = row_count(context, &table_name).saturating_sub(before_rows);
961 }
962 if produced.text.is_empty() {
963 produced.text = format!("imported {} rows into {table_name}", produced.changes);
964 }
965 Ok(produced)
966}
967
968fn row_count(context: &mut Context, table: &str) -> i64 {
973 let sql = format!("SELECT count(*) FROM \"{}\"", table.replace('"', "\"\""));
974 context
975 .shell()
976 .scalar(&sql)
977 .and_then(|text| text.parse::<i64>().ok())
978 .unwrap_or(0)
979}
980
981pub fn export(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
983 let sql = match (arguments.text("sql"), arguments.text("table")) {
984 (Some(_), Some(_)) => {
985 return Err(Failed::misuse(
986 "export accepts either 'sql' or 'table', not both.",
987 ))
988 }
989 (Some(sql), None) => sql.to_string(),
990 (None, Some(name)) => format!("SELECT * FROM {}", quoted(name)),
991 (None, None) => return Err(Failed::misuse("export needs either 'sql' or 'table'.")),
992 };
993 let format = arguments.text("format").unwrap_or("csv").to_string();
994 let mode = match format.as_str() {
995 "csv" | "json" | "tabs" | "markdown" | "insert" | "quote" | "line" | "html" => format,
996 other => {
997 return Err(Failed::misuse(format!(
998 "'{other}' is not an export format. Use csv, json, tabs, markdown, insert, \
999 quote, line or html."
1000 )))
1001 }
1002 };
1003 context.refuse_if_it_writes(&sql)?;
1004 let destination = match arguments.text("out") {
1021 None => None,
1022 Some(out) => Some(context.confine(out)?),
1023 };
1024 if let Some(path) = destination.as_ref() {
1025 let named = path.to_string_lossy().into_owned();
1026 context
1027 .shell()
1028 .redirect(Some(&named), true)
1029 .map_err(|message| {
1030 Failed::said(Status::Io, format!("cannot open \"{named}\": {message}"))
1031 })?;
1032 }
1033 let script = format!(".mode {mode}\n.headers on\n{sql};");
1034 context.shell().header_when_empty = true;
1038 let printed = context.collect_output(&script);
1039 context.shell().header_when_empty = false;
1040 let rows = context.shell().rows_since_redirect;
1041 if destination.is_some() {
1046 let _ = context.shell().redirect(None, false);
1047 }
1048 if let Some(failed) = take_failure(context, &printed) {
1049 return Err(failed);
1050 }
1051 let Some(path) = destination else {
1052 return Ok(Outcome::said("export", printed.trim_end()));
1053 };
1054 wrote_a_file(&path, rows)
1055}
1056
1057fn wrote_a_file(path: &std::path::Path, rows: usize) -> Result<Outcome, Failed> {
1071 let named = path.to_string_lossy().into_owned();
1072 let bytes = std::fs::metadata(path)
1073 .map(|found| found.len())
1074 .unwrap_or(0);
1075 let mut produced = Outcome::said(
1076 "export",
1077 format!(
1078 "wrote {rows} row{} ({bytes} bytes) to {named}",
1079 if rows == 1 { "" } else { "s" }
1080 ),
1081 );
1082 produced.total = rows;
1083 Ok(produced
1084 .with("wrote", json::text(&named))
1085 .with("bytes", Json::Int(bytes as i64)))
1086}
1087
1088pub fn backup(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1090 let file = arguments.required_text("file")?.to_string();
1091 let confined = context.confine(&file)?;
1092 let named = confined.to_string_lossy().into_owned();
1093 context
1094 .shell()
1095 .backup_to(&named)
1096 .map_err(|message| Failed::said(Status::Io, message))?;
1097 Ok(Outcome::said("backup", format!("wrote {named}")).with("wrote", json::text(&named)))
1098}
1099
1100pub fn encrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1106 let Some(key) = crate::keys::configured() else {
1107 return Err(Failed::said(
1108 Status::InvalidState,
1109 "encrypt needs a key for the copy: pass --key-file or set INILLUCENT_KEY",
1110 ));
1111 };
1112 let named = copy_target(context, arguments)?;
1113 context
1114 .shell()
1115 .export_to(&named, Some(key))
1116 .map_err(|error| Failed::from_engine(&error))?;
1117 Ok(
1118 Outcome::said("encrypt", format!("wrote {named}, encrypted"))
1119 .with("wrote", json::text(&named))
1120 .with("encryption", json::text(inillucent_driver::CIPHER_NAME)),
1121 )
1122}
1123
1124pub fn decrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1126 if !context.shell().is_encrypted() {
1127 return Err(Failed::said(
1128 Status::InvalidState,
1129 "this database is not encrypted, so there is nothing to decrypt",
1130 ));
1131 }
1132 let named = copy_target(context, arguments)?;
1133 context
1134 .shell()
1135 .export_to(&named, None)
1136 .map_err(|error| Failed::from_engine(&error))?;
1137 Ok(
1138 Outcome::said("decrypt", format!("wrote {named}, not encrypted"))
1139 .with("wrote", json::text(&named))
1140 .with("encryption", json::text("none")),
1141 )
1142}
1143
1144pub fn rekey(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1149 let key = match arguments.text("new-key-file") {
1150 Some(path) => {
1151 let confined = context.confine(path)?;
1152 crate::keys::read_key_file(&confined.to_string_lossy())
1153 .map_err(|message| Failed::said(Status::InvalidState, message))?
1154 }
1155 None => match std::env::var(crate::keys::NEW_KEY_VARIABLE) {
1156 Ok(text) if !text.is_empty() => inillucent_driver::EncryptionKey::parse(&text),
1157 _ => {
1158 return Err(Failed::said(
1159 Status::InvalidState,
1160 "rekey needs the new key: pass --new-key-file or set INILLUCENT_NEW_KEY",
1161 ))
1162 }
1163 },
1164 };
1165 context
1166 .shell()
1167 .rekey(key)
1168 .map_err(|error| Failed::from_engine(&error))?;
1169 Ok(Outcome::said("rekey", "the key was changed".to_string()))
1170}
1171
1172fn copy_target(context: &Context, arguments: &Arguments) -> Result<String, Failed> {
1177 let file = arguments.required_text("file")?.to_string();
1178 let confined = context.confine(&file)?;
1179 Ok(confined.to_string_lossy().into_owned())
1180}
1181
1182pub fn restore(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1184 let file = arguments.required_text("file")?.to_string();
1185 let confined = context.confine(&file)?;
1186 if !confined.is_file() {
1194 return Err(Failed::said(
1195 Status::NotFound,
1196 format!(
1197 "{}: there is no such backup file to restore from",
1198 confined.to_string_lossy()
1199 ),
1200 ));
1201 }
1202 dot(
1203 context,
1204 "restore",
1205 &format!(".restore \"{}\"", confined.to_string_lossy()),
1206 )
1207}
1208
1209pub fn checkpoint(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1211 listing(context, "checkpoint", "PRAGMA wal_checkpoint")
1212}
1213
1214pub fn integrity_check(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1227 let produced = listing(context, "integrity-check", "PRAGMA integrity_check")?;
1228 if let Some(damage) = first_damage(&produced) {
1229 return Err(Failed::said(Status::Corrupt, damage));
1230 }
1231 Ok(produced)
1232}
1233
1234fn first_damage(produced: &Outcome) -> Option<String> {
1244 let said: Vec<String> = produced
1245 .rows
1246 .iter()
1247 .flatten()
1248 .map(|value| match value {
1249 Json::Text(text) => text.clone(),
1250 other => format!("{other:?}"),
1251 })
1252 .collect();
1253 if said.is_empty() {
1254 return Some(
1255 "PRAGMA integrity_check returned no rows at all, so this database's soundness is \
1256 unknown rather than confirmed"
1257 .to_string(),
1258 );
1259 }
1260 if said.iter().all(|line| line.trim() == "ok") {
1261 return None;
1262 }
1263 Some(said.join("; "))
1264}
1265
1266pub fn analyze(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1268 let sql = match arguments.text("table") {
1269 Some(name) => format!("ANALYZE {}", quoted(name)),
1270 None => "ANALYZE".to_string(),
1271 };
1272 context
1275 .shell()
1276 .connection()
1277 .execute_batch(&sql)
1278 .map_err(|error| Failed::from_engine(&error))?;
1279 Ok(Outcome::said("analyze", "ok. sqlite_stat1 is up to date."))
1280}
1281
1282pub fn stats(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1284 let cache = context.shell().cache_stats();
1285 let pool = context.shell().pool_bytes();
1286 let pages = context
1287 .shell()
1288 .scalar("PRAGMA page_count")
1289 .unwrap_or_default();
1290 let size = context
1291 .shell()
1292 .scalar("PRAGMA page_size")
1293 .unwrap_or_default();
1294 let free = context
1295 .shell()
1296 .scalar("PRAGMA freelist_count")
1297 .unwrap_or_default();
1298 let text = format!(
1299 "pool bytes: {pool}\npage size: {size}\npage count: {pages}\n\
1300 free pages: {free}\ncache hits: {}\ncache misses: {}",
1301 cache.hits, cache.misses
1302 );
1303 Ok(Outcome::said("stats", text)
1304 .with("pool_bytes", Json::Int(pool as i64))
1305 .with("page_size", Json::Int(size.parse::<i64>().unwrap_or(0)))
1306 .with("page_count", Json::Int(pages.parse::<i64>().unwrap_or(0)))
1307 .with("free_pages", Json::Int(free.parse::<i64>().unwrap_or(0)))
1308 .with("cache_hits", Json::Int(cache.hits as i64))
1309 .with("cache_misses", Json::Int(cache.misses as i64)))
1310}
1311
1312fn neighbours_asked_for(arguments: &Arguments) -> Result<i64, Failed> {
1322 let k = arguments.integer("k").unwrap_or(10);
1323 if k < 1 {
1324 return Err(Failed::misuse(
1325 "'k' has to be one or more: it is how many rows to return.",
1326 ));
1327 }
1328 Ok(k)
1329}
1330
1331pub fn search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1338 let query_text = arguments.required_text("query")?.to_string();
1339 let name = arguments.required_text("table")?.to_string();
1340 let k = neighbours_asked_for(arguments)?;
1341 let reranked = match arguments.flag("rerank") {
1344 true => format!(" AND question = {} AND k = {k}", quoted_text(&query_text)),
1345 false => String::new(),
1346 };
1347 let sql = format!(
1348 "SELECT rowid, * FROM {0} WHERE {0} MATCH {1}{reranked} ORDER BY rank LIMIT {k}",
1349 quoted(&name),
1350 quoted_text(&query_text)
1351 );
1352 let mut produced = produce(context, "search", &sql, &[], 0)?;
1353 produced.command = "search".to_string();
1354 Ok(produced.with("query", json::text(&query_text)))
1355}
1356
1357pub fn vector_search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1359 let name = arguments.required_text("table")?.to_string();
1360 let column = arguments.required_text("column")?.to_string();
1361 let numbers = arguments.values("vector");
1362 if numbers.is_empty() {
1363 return Err(Failed::misuse(
1364 "'vector' has to be an array of numbers, one per dimension.",
1365 ));
1366 }
1367 let mut blob = String::from("x'");
1368 for value in &numbers {
1369 let Some(number) = value.integer().map(|whole| whole as f64).or(match value {
1370 Json::Real(real) => Some(*real),
1371 _ => None,
1372 }) else {
1373 return Err(Failed::misuse(
1374 "every element of 'vector' has to be a number.",
1375 ));
1376 };
1377 for byte in (number as f32).to_bits().to_le_bytes() {
1378 blob.push_str(&format!("{byte:02x}"));
1379 }
1380 }
1381 blob.push('\'');
1382 let k = neighbours_asked_for(arguments)?;
1383 let measure = arguments.text("measure").unwrap_or("cos");
1384 let function = match measure {
1385 "cos" => "vector_distance_cos",
1386 "l2" => "vector_distance_l2",
1387 "dot" => "vector_dot",
1388 other => {
1389 return Err(Failed::misuse(format!(
1390 "'{other}' is not a measure. Use cos, l2 or dot."
1391 )))
1392 }
1393 };
1394 let shape = searched_table_shape(context, &name);
1395 let rowid = if shape.integer_key { "" } else { "rowid, " };
1399 let sql = format!(
1400 "SELECT {rowid}*, {function}({1}, {blob}) AS distance FROM {0} \
1401 WHERE {1} IS NOT NULL ORDER BY {function}({1}, {blob}) LIMIT {k}",
1402 quoted(&name),
1403 quoted(&column)
1404 );
1405 let mut produced = produce(context, "vector-search", &sql, &[], 0)?;
1406 let offset = usize::from(!shape.integer_key);
1407 let positions: Vec<usize> = shape.vectors.iter().map(|nth| nth + offset).collect();
1408 for row in &mut produced.rows {
1409 for &nth in &positions {
1410 if let Some(cell) = row.get_mut(nth) {
1411 if let Some(numbers) = vector_numbers(cell) {
1412 *cell = numbers;
1413 }
1414 }
1415 }
1416 }
1417 if !positions.is_empty() {
1418 let names: Vec<String> = produced.columns.iter().map(|c| c.name.clone()).collect();
1419 produced.columns = columns_from(&names, &produced.rows);
1420 produced.text = table(&produced.columns, &produced.rows, &context.null);
1421 }
1422 Ok(produced)
1423}
1424
1425struct SearchedTable {
1427 integer_key: bool,
1429 vectors: Vec<usize>,
1431}
1432
1433fn searched_table_shape(context: &mut Context, name: &str) -> SearchedTable {
1442 let info = context
1443 .shell()
1444 .collect(&format!("PRAGMA table_info({})", quoted(name)))
1445 .map(|(_, rows)| rows)
1446 .unwrap_or_default();
1447 let text_of = |value: Option<&Value<'static>>| match value {
1448 Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).to_ascii_uppercase(),
1449 _ => String::new(),
1450 };
1451 let key_of = |row: &Vec<Value<'static>>| match row.get(5) {
1452 Some(Value::Integer(key)) => *key,
1453 _ => 0,
1454 };
1455 let keys: Vec<&Vec<Value<'static>>> = info.iter().filter(|row| key_of(row) > 0).collect();
1456 let integer_key = matches!(keys.as_slice(), [only] if text_of(only.get(2)) == "INTEGER");
1457 let vectors = info
1458 .iter()
1459 .enumerate()
1460 .filter(|(_, row)| text_of(row.get(2)).starts_with("VECTOR"))
1461 .map(|(nth, _)| nth)
1462 .collect();
1463 SearchedTable {
1464 integer_key,
1465 vectors,
1466 }
1467}
1468
1469fn vector_numbers(cell: &Json) -> Option<Json> {
1477 let hex = cell.get("blob").and_then(Json::text)?;
1478 let bytes: Vec<u8> = hex
1479 .as_bytes()
1480 .chunks(2)
1481 .map(|pair| {
1482 std::str::from_utf8(pair)
1483 .ok()
1484 .and_then(|digits| u8::from_str_radix(digits, 16).ok())
1485 })
1486 .collect::<Option<Vec<u8>>>()?;
1487 if !bytes.len().is_multiple_of(4) {
1488 return None;
1489 }
1490 let numbers = bytes
1491 .chunks_exact(4)
1492 .map(|word| {
1493 let mut four = [0u8; 4];
1494 four.copy_from_slice(word);
1495 Json::Real(f64::from(f32::from_le_bytes(four)))
1496 })
1497 .collect();
1498 Some(Json::Array(numbers))
1499}
1500
1501pub fn capabilities(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1503 let wanted = arguments.text("name");
1504 let rows: Vec<Vec<Json>> = inillucent_driver::CAPABILITIES
1505 .iter()
1506 .filter(|entry| wanted.is_none_or(|name| entry.name == name))
1507 .map(|entry| {
1508 vec![
1509 json::text(entry.name),
1510 json::text(support_name(entry.support)),
1511 json::text(entry.note),
1512 ]
1513 })
1514 .collect();
1515 if rows.is_empty() {
1516 return Err(Failed::said(
1517 Status::NotFound,
1518 format!(
1519 "no capability named \"{}\". An unknown name means no, never yes: a capability \
1520 that was never declared was never checked.",
1521 wanted.unwrap_or_default()
1522 ),
1523 ));
1524 }
1525 let names = vec![
1526 "capability".to_string(),
1527 "support".to_string(),
1528 "note".to_string(),
1529 ];
1530 let columns = columns_from(&names, &rows);
1531 let text = wrapped_notes(&rows);
1537 Ok(Outcome {
1538 command: "capabilities".to_string(),
1539 total: rows.len(),
1540 rows,
1541 columns,
1542 more: false,
1543 changes: 0,
1544 last_insert_rowid: 0,
1545 elapsed_ms: 0.0,
1546 text,
1547 extra: Vec::new(),
1548 })
1549}
1550
1551fn wrapped_notes(rows: &[Vec<Json>]) -> String {
1555 let mut lines = Vec::with_capacity(rows.len() * 3);
1556 for row in rows {
1557 let name = row.first().and_then(Json::text).unwrap_or_default();
1558 let support = row.get(1).and_then(Json::text).unwrap_or_default();
1559 let note = row.get(2).and_then(Json::text).unwrap_or_default();
1560 lines.push(format!("{name:<22} {support}"));
1561 for line in wrap(note, 74) {
1562 lines.push(format!(" {line}"));
1563 }
1564 }
1565 lines.join(
1566 "
1567",
1568 )
1569}
1570
1571fn wrap(text: &str, width: usize) -> Vec<String> {
1580 let mut lines = Vec::new();
1581 let mut current = String::new();
1582 for word in text.split_whitespace() {
1583 if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
1584 lines.push(std::mem::take(&mut current));
1585 }
1586 if !current.is_empty() {
1587 current.push(' ');
1588 }
1589 current.push_str(word);
1590 }
1591 if !current.is_empty() {
1592 lines.push(current);
1593 }
1594 lines
1595}
1596
1597fn support_name(support: Support) -> &'static str {
1601 match support {
1602 Support::Yes => "yes",
1603 Support::Partial => "partial",
1604 Support::No => "no",
1605 }
1606}
1607
1608pub fn functions(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1610 let mut sql = String::from("PRAGMA function_list");
1611 let produced = listing(context, "functions", &sql);
1612 let mut produced = produced?;
1617 if let Some(pattern) = arguments.text("pattern") {
1618 produced.rows.retain(|row| {
1619 row.first()
1620 .and_then(Json::text)
1621 .is_some_and(|name| like_matches(pattern, name))
1622 });
1623 produced.total = produced.rows.len();
1624 produced.text = table(&produced.columns, &produced.rows, &context.null);
1625 }
1626 sql.clear();
1627 Ok(produced)
1628}
1629
1630fn like_matches(pattern: &str, name: &str) -> bool {
1641 let pattern: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
1642 let name: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
1643 let (mut p, mut n) = (0usize, 0usize);
1647 let mut star: Option<(usize, usize)> = None;
1648 while n < name.len() {
1649 match pattern.get(p) {
1650 Some('%') => {
1651 star = Some((p, n));
1652 p += 1;
1653 }
1654 Some(&c) if c == '_' || name.get(n) == Some(&c) => {
1655 p += 1;
1656 n += 1;
1657 }
1658 _ => match star {
1659 Some((star_p, star_n)) => {
1660 p = star_p + 1;
1661 n = star_n + 1;
1662 star = Some((star_p, star_n + 1));
1663 }
1664 None => return false,
1665 },
1666 }
1667 }
1668 pattern
1669 .get(p..)
1670 .is_some_and(|rest| rest.iter().all(|&c| c == '%'))
1671}
1672
1673pub fn migrate(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1685 let destination = arguments.required_text("destination")?.to_string();
1686 let source = resolve_source(context, arguments.text("source"))?;
1687 let kind = arguments
1688 .text("kind")
1689 .map(str::to_string)
1690 .unwrap_or_else(|| kind_of_source(&source));
1691 if kind == "postgres" || kind == "mysql" {
1692 return migrate_remote(context, &source, &destination, arguments);
1693 }
1694
1695 let from = context.confine(&source)?;
1696 let to = context.confine(&destination)?;
1697 if !from.exists() {
1698 return Err(Failed::said(
1699 Status::NotFound,
1700 format!("there is no \"{source}\" to migrate from."),
1701 ));
1702 }
1703 if to.exists() {
1704 return Err(Failed::said(
1705 Status::InvalidState,
1706 format!("\"{destination}\" already exists. This tool never overwrites."),
1707 ));
1708 }
1709 match kind.as_str() {
1710 "sqlite" => migrate_sqlite_file(&from, &to),
1711 "index" => Err(Failed::unsupported(
1712 "migrate --kind index",
1713 "the retrieval-index migration runs in inillucent-migrate, which links the retrieval \
1714 engine. Run: inillucent-migrate <source-index-dir> <destination.db>",
1715 )),
1716 other => Err(Failed::misuse(format!(
1717 "'{other}' is not a migration kind. Use sqlite, postgres, mysql or index."
1718 ))),
1719 }
1720}
1721
1722const SOURCE_URL_VARIABLE: &str = "INILLUCENT_SOURCE_URL";
1724
1725fn resolve_source(context: &Context, argument: Option<&str>) -> Result<String, Failed> {
1747 if let Some(source) = argument {
1748 if source != "-" {
1749 return Ok(source.to_string());
1750 }
1751 }
1752 if let Ok(held) = std::env::var(SOURCE_URL_VARIABLE) {
1753 if !held.trim().is_empty() {
1754 return Ok(held);
1755 }
1756 }
1757 if argument == Some("-") {
1758 if context.confined() {
1759 return Err(Failed::said(
1760 Status::InvalidState,
1761 "this surface is confined to a directory with --root, and '-' reads the source \
1762 from standard input, which such a surface does not have to itself. Set \
1763 INILLUCENT_SOURCE_URL instead.",
1764 ));
1765 }
1766 let mut line = String::new();
1767 std::io::stdin()
1768 .read_line(&mut line)
1769 .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
1770 let line = line.trim_end_matches(['\r', '\n']).to_string();
1771 if line.is_empty() {
1772 return Err(Failed::misuse(
1773 "standard input held no source. Write the file path or the connection URL on one \
1774 line.",
1775 ));
1776 }
1777 return Ok(line);
1778 }
1779 Err(Failed::misuse(format!(
1780 "migrate needs a source: a database file, or a postgres:// or mysql:// URL. Pass it as \
1781 the first argument, set {SOURCE_URL_VARIABLE}, or pass '-' to read one line from \
1782 standard input."
1783 )))
1784}
1785
1786fn kind_of_source(source: &str) -> String {
1790 match inillucent_remote::ConnectionUrl::parse(source) {
1791 Ok(url) => url.scheme.name().to_string(),
1792 Err(_) => "sqlite".to_string(),
1793 }
1794}
1795
1796fn migrate_remote(
1810 context: &mut Context,
1811 source: &str,
1812 destination: &str,
1813 arguments: &Arguments,
1814) -> Result<Outcome, Failed> {
1815 if context.confined() {
1816 return Err(Failed::said(
1817 Status::InvalidState,
1818 "this surface is confined to a directory with --root, and a migration from a server \
1819 reaches a host and a port rather than a path. Run it from an unconfined command \
1820 line.",
1821 ));
1822 }
1823 let url = inillucent_remote::ConnectionUrl::parse(source)
1824 .map_err(|error| Failed::misuse(error.detail().unwrap_or_else(|| error.message())))?;
1825 let to = context.confine(destination)?;
1826 if to.exists() {
1827 return Err(Failed::said(
1828 Status::InvalidState,
1829 format!("\"{destination}\" already exists. This tool never overwrites."),
1830 ));
1831 }
1832 let mut plan = inillucent_remote::Plan::new(url, &to);
1833 plan.limits = Some(context.limits());
1838 if let Some(batch) = arguments.integer("batch") {
1839 plan.batch = (batch.max(1)) as u64;
1840 }
1841 plan.insecure_plaintext = arguments.flag("insecure-plaintext");
1842 plan.transport().map_err(|error| {
1846 Failed::said(
1847 Status::InvalidState,
1848 error.detail().unwrap_or_else(|| error.message()),
1849 )
1850 })?;
1851 let report = inillucent_remote::migrate::migrate(&plan).map_err(|error| {
1852 Failed::said(
1853 Status::Io,
1854 error.detail().unwrap_or_else(|| error.message()),
1855 )
1856 })?;
1857
1858 let checks: Vec<json::Json> = report
1859 .checks
1860 .iter()
1861 .map(|check| {
1862 json::object(vec![
1863 ("name", json::text(&check.name)),
1864 ("passed", json::Json::Bool(check.passed)),
1865 ("detail", json::text(&check.detail)),
1866 ])
1867 })
1868 .collect();
1869 let tables: Vec<json::Json> = report
1870 .tables
1871 .iter()
1872 .map(|table| {
1873 json::object(vec![
1874 ("source", json::text(&table.source)),
1875 ("destination", json::text(&table.target)),
1876 ("rows", json::Json::Int(table.rows as i64)),
1877 ("digest", json::text(&table.digest)),
1878 ])
1879 })
1880 .collect();
1881 let not_carried: Vec<json::Json> = report
1882 .not_carried
1883 .iter()
1884 .map(|(kind, name)| {
1885 json::object(vec![("kind", json::text(kind)), ("name", json::text(name))])
1886 })
1887 .collect();
1888
1889 let mut text = format!(
1894 "{} -> {}\n{}, {} tables, {} rows\ntransport: {}\n",
1895 report.source,
1896 to.display(),
1897 report.server,
1898 report.tables.len(),
1899 report.rows(),
1900 report.transport
1901 );
1902 for check in &report.checks {
1903 text.push_str(&format!(" {}\n", check.line()));
1904 }
1905 if !report.passed() {
1906 text.push_str(&format!(
1907 "verification failed; nothing was published. The staging file is at {}",
1908 report.staged.display()
1909 ));
1910 return Err(Failed::said(Status::Io, text));
1911 }
1912 text.push_str(&format!("published: {}", to.display()));
1913
1914 Ok(Outcome::said("migrate", text)
1915 .with("destination", json::text(to.to_string_lossy()))
1916 .with("transport", json::text(&report.transport))
1919 .with("source", json::text(&report.source))
1922 .with("server", json::text(&report.server))
1923 .with("rows", json::Json::Int(report.rows() as i64))
1924 .with("tables", json::Json::Array(tables))
1925 .with("checks", json::Json::Array(checks))
1926 .with("notCarried", json::Json::Array(not_carried)))
1927}
1928
1929fn migrate_sqlite_file(from: &std::path::Path, to: &std::path::Path) -> Result<Outcome, Failed> {
1954 let report = inillucent_migrate::sqlite::migrate(from, to)
1957 .map_err(|error| Failed::from_engine(&error))?;
1958 let failures: Vec<String> = report
1959 .failures()
1960 .iter()
1961 .map(|check| format!("{}: {}", check.name, check.detail))
1962 .collect();
1963 let checks = Json::Array(
1964 report
1965 .checks
1966 .iter()
1967 .map(|check| {
1968 json::object(vec![
1969 ("name", json::text(&check.name)),
1970 ("passed", Json::Bool(check.passed)),
1971 ("detail", json::text(&check.detail)),
1972 ])
1973 })
1974 .collect(),
1975 );
1976 if !report.passed() {
1980 return Err(Failed::said(
1981 Status::Corrupt,
1982 format!(
1983 "{} was not published: {}",
1984 to.display(),
1985 failures.join("; ")
1986 ),
1987 ));
1988 }
1989 Ok(Outcome::said(
1990 "migrate",
1991 format!("imported {} into {}", from.display(), to.display()),
1992 )
1993 .with("destination", json::text(to.to_string_lossy()))
1994 .with("checks", checks))
1995}
1996
1997pub fn version(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1999 let printed = context.collect_output(".version");
2000 context.shell().failed = false;
2001 context.shell().first_error = None;
2002 let text = format!(
2003 "{}
2004inillucent-cli {}
2005{}",
2006 printed.trim_end(),
2007 env!("CARGO_PKG_VERSION"),
2008 inillucent_driver::version()
2009 );
2010 Ok(Outcome::said("version", text)
2011 .with("cli", json::text(env!("CARGO_PKG_VERSION")))
2012 .with("driver", json::text(inillucent_driver::version())))
2013}
2014
2015pub fn help(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
2017 match arguments.text("topic") {
2018 None => {
2019 let rows: Vec<Vec<Json>> = super::COMMANDS
2020 .iter()
2021 .map(|command| vec![json::text(command.name), json::text(command.summary)])
2022 .collect();
2023 let names = vec!["command".to_string(), "what it does".to_string()];
2024 let columns = columns_from(&names, &rows);
2025 let text = table(&columns, &rows, "");
2026 Ok(Outcome {
2027 command: "help".to_string(),
2028 total: rows.len(),
2029 rows,
2030 columns,
2031 more: false,
2032 changes: 0,
2033 last_insert_rowid: 0,
2034 elapsed_ms: 0.0,
2035 text,
2036 extra: Vec::new(),
2037 })
2038 }
2039 Some(topic) => {
2040 let Some(command) = super::find(topic) else {
2041 return Err(Failed::said(
2042 Status::NotFound,
2043 format!("there is no '{topic}' command. Run 'inillucent help' for the list."),
2044 ));
2045 };
2046 let mut text = format!(
2047 "{}\n\n{}\n\n{}",
2048 command.usage(),
2049 command.summary,
2050 command.detail
2051 );
2052 if !command.params.is_empty() {
2053 text.push_str("\n\nParameters:");
2054 for param in command.params {
2055 text.push_str(&format!(
2056 "\n {:<12} {}{}",
2057 param.name,
2058 if param.required { "(required) " } else { "" },
2059 param.description
2060 ));
2061 }
2062 }
2063 Ok(Outcome::said("help", text))
2064 }
2065 }
2066}
2067
2068fn front_end_only(name: &'static str) -> Failed {
2077 Failed::misuse(format!(
2078 "'{name}' is run by the inillucent binary itself and cannot be dispatched here."
2079 ))
2080}
2081
2082pub fn shell_placeholder(
2084 _context: &mut Context,
2085 _arguments: &Arguments,
2086) -> Result<Outcome, Failed> {
2087 Err(front_end_only("shell"))
2088}
2089
2090pub fn mcp_placeholder(_context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
2092 Err(front_end_only("mcp"))
2093}
2094
2095#[cfg(test)]
2096mod source_tests {
2097 use super::*;
2098 use crate::shell::Shell;
2099
2100 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2114 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2115 LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
2116 }
2117
2118 fn context(root: Option<std::path::PathBuf>) -> Context {
2122 Context::for_test(
2123 Shell::open(":memory:").expect("a memory database opens"),
2124 root,
2125 )
2126 }
2127
2128 #[test]
2130 fn an_argument_is_the_source() {
2131 let context = context(None);
2132 let held = resolve_source(&context, Some("postgres://user@host/db"))
2133 .expect("the argument is accepted");
2134 assert_eq!(held, "postgres://user@host/db");
2135 }
2136
2137 #[test]
2142 fn the_environment_supplies_a_source_that_was_not_an_argument() {
2143 let _held = env_guard();
2144 let context = context(None);
2145 std::env::set_var(SOURCE_URL_VARIABLE, "postgres://user:secret@host/db");
2146 let held = resolve_source(&context, None).expect("the variable is read");
2147 std::env::remove_var(SOURCE_URL_VARIABLE);
2148 assert_eq!(held, "postgres://user:secret@host/db");
2149 }
2150
2151 #[test]
2153 fn no_source_anywhere_is_refused_by_name() {
2154 let _held = env_guard();
2155 let context = context(None);
2156 std::env::remove_var(SOURCE_URL_VARIABLE);
2157 let error = resolve_source(&context, None).expect_err("there is no source");
2158 let said = format!("{error:?}");
2159 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2160 assert!(said.contains("standard input"), "{said}");
2161 }
2162
2163 #[test]
2169 fn a_confined_surface_refuses_to_read_standard_input() {
2170 let _held = env_guard();
2171 let context = context(Some(std::env::temp_dir()));
2172 std::env::remove_var(SOURCE_URL_VARIABLE);
2173 let error = resolve_source(&context, Some("-")).expect_err("a confined surface refuses");
2174 let said = format!("{error:?}");
2175 assert!(said.contains("--root"), "{said}");
2176 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2177 }
2178
2179 #[test]
2181 fn export_refuses_table_and_sql_together() {
2182 let mut arguments = Arguments::default();
2183 arguments.set("table", crate::json::text("expected"));
2184 arguments.set("sql", crate::json::text("SELECT 'other' AS v"));
2185 let failure = export(&mut context(None), &arguments)
2186 .expect_err("export must require one data source");
2187 assert!(failure.message.contains("not both"), "{}", failure.message);
2188 }
2189
2190 #[test]
2195 fn the_environment_wins_over_reading_standard_input() {
2196 let _held = env_guard();
2197 let context = context(None);
2198 std::env::set_var(SOURCE_URL_VARIABLE, "mysql://user@host/db");
2199 let held = resolve_source(&context, Some("-")).expect("the variable is read");
2200 std::env::remove_var(SOURCE_URL_VARIABLE);
2201 assert_eq!(held, "mysql://user@host/db");
2202 }
2203}