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 let printed = context.collect_output(&script);
1035 let rows = context.shell().rows_since_redirect;
1036 if destination.is_some() {
1041 let _ = context.shell().redirect(None, false);
1042 }
1043 if let Some(failed) = take_failure(context, &printed) {
1044 return Err(failed);
1045 }
1046 let Some(path) = destination else {
1047 return Ok(Outcome::said("export", printed.trim_end()));
1048 };
1049 wrote_a_file(&path, rows)
1050}
1051
1052fn wrote_a_file(path: &std::path::Path, rows: usize) -> Result<Outcome, Failed> {
1066 let named = path.to_string_lossy().into_owned();
1067 let bytes = std::fs::metadata(path)
1068 .map(|found| found.len())
1069 .unwrap_or(0);
1070 let mut produced = Outcome::said(
1071 "export",
1072 format!(
1073 "wrote {rows} row{} ({bytes} bytes) to {named}",
1074 if rows == 1 { "" } else { "s" }
1075 ),
1076 );
1077 produced.total = rows;
1078 Ok(produced
1079 .with("wrote", json::text(&named))
1080 .with("bytes", Json::Int(bytes as i64)))
1081}
1082
1083pub fn backup(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1085 let file = arguments.required_text("file")?.to_string();
1086 let confined = context.confine(&file)?;
1087 let named = confined.to_string_lossy().into_owned();
1088 context
1089 .shell()
1090 .backup_to(&named)
1091 .map_err(|message| Failed::said(Status::Io, message))?;
1092 Ok(Outcome::said("backup", format!("wrote {named}")).with("wrote", json::text(&named)))
1093}
1094
1095pub fn encrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1101 let Some(key) = crate::keys::configured() else {
1102 return Err(Failed::said(
1103 Status::InvalidState,
1104 "encrypt needs a key for the copy: pass --key-file or set INILLUCENT_KEY",
1105 ));
1106 };
1107 let named = copy_target(context, arguments)?;
1108 context
1109 .shell()
1110 .export_to(&named, Some(key))
1111 .map_err(|error| Failed::from_engine(&error))?;
1112 Ok(
1113 Outcome::said("encrypt", format!("wrote {named}, encrypted"))
1114 .with("wrote", json::text(&named))
1115 .with("encryption", json::text(inillucent_driver::CIPHER_NAME)),
1116 )
1117}
1118
1119pub fn decrypt(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1121 if !context.shell().is_encrypted() {
1122 return Err(Failed::said(
1123 Status::InvalidState,
1124 "this database is not encrypted, so there is nothing to decrypt",
1125 ));
1126 }
1127 let named = copy_target(context, arguments)?;
1128 context
1129 .shell()
1130 .export_to(&named, None)
1131 .map_err(|error| Failed::from_engine(&error))?;
1132 Ok(
1133 Outcome::said("decrypt", format!("wrote {named}, not encrypted"))
1134 .with("wrote", json::text(&named))
1135 .with("encryption", json::text("none")),
1136 )
1137}
1138
1139pub fn rekey(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1144 let key = match arguments.text("new-key-file") {
1145 Some(path) => {
1146 let confined = context.confine(path)?;
1147 crate::keys::read_key_file(&confined.to_string_lossy())
1148 .map_err(|message| Failed::said(Status::InvalidState, message))?
1149 }
1150 None => match std::env::var(crate::keys::NEW_KEY_VARIABLE) {
1151 Ok(text) if !text.is_empty() => inillucent_driver::EncryptionKey::parse(&text),
1152 _ => {
1153 return Err(Failed::said(
1154 Status::InvalidState,
1155 "rekey needs the new key: pass --new-key-file or set INILLUCENT_NEW_KEY",
1156 ))
1157 }
1158 },
1159 };
1160 context
1161 .shell()
1162 .rekey(key)
1163 .map_err(|error| Failed::from_engine(&error))?;
1164 Ok(Outcome::said("rekey", "the key was changed".to_string()))
1165}
1166
1167fn copy_target(context: &Context, arguments: &Arguments) -> Result<String, Failed> {
1172 let file = arguments.required_text("file")?.to_string();
1173 let confined = context.confine(&file)?;
1174 Ok(confined.to_string_lossy().into_owned())
1175}
1176
1177pub fn restore(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1179 let file = arguments.required_text("file")?.to_string();
1180 let confined = context.confine(&file)?;
1181 if !confined.is_file() {
1189 return Err(Failed::said(
1190 Status::NotFound,
1191 format!(
1192 "{}: there is no such backup file to restore from",
1193 confined.to_string_lossy()
1194 ),
1195 ));
1196 }
1197 dot(
1198 context,
1199 "restore",
1200 &format!(".restore \"{}\"", confined.to_string_lossy()),
1201 )
1202}
1203
1204pub fn checkpoint(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1206 listing(context, "checkpoint", "PRAGMA wal_checkpoint")
1207}
1208
1209pub fn integrity_check(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1222 let produced = listing(context, "integrity-check", "PRAGMA integrity_check")?;
1223 if let Some(damage) = first_damage(&produced) {
1224 return Err(Failed::said(Status::Corrupt, damage));
1225 }
1226 Ok(produced)
1227}
1228
1229fn first_damage(produced: &Outcome) -> Option<String> {
1239 let said: Vec<String> = produced
1240 .rows
1241 .iter()
1242 .flatten()
1243 .map(|value| match value {
1244 Json::Text(text) => text.clone(),
1245 other => format!("{other:?}"),
1246 })
1247 .collect();
1248 if said.is_empty() {
1249 return Some(
1250 "PRAGMA integrity_check returned no rows at all, so this database's soundness is \
1251 unknown rather than confirmed"
1252 .to_string(),
1253 );
1254 }
1255 if said.iter().all(|line| line.trim() == "ok") {
1256 return None;
1257 }
1258 Some(said.join("; "))
1259}
1260
1261pub fn analyze(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1263 let sql = match arguments.text("table") {
1264 Some(name) => format!("ANALYZE {}", quoted(name)),
1265 None => "ANALYZE".to_string(),
1266 };
1267 context
1270 .shell()
1271 .connection()
1272 .execute_batch(&sql)
1273 .map_err(|error| Failed::from_engine(&error))?;
1274 Ok(Outcome::said("analyze", "ok. sqlite_stat1 is up to date."))
1275}
1276
1277pub fn stats(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1279 let cache = context.shell().cache_stats();
1280 let pool = context.shell().pool_bytes();
1281 let pages = context
1282 .shell()
1283 .scalar("PRAGMA page_count")
1284 .unwrap_or_default();
1285 let size = context
1286 .shell()
1287 .scalar("PRAGMA page_size")
1288 .unwrap_or_default();
1289 let free = context
1290 .shell()
1291 .scalar("PRAGMA freelist_count")
1292 .unwrap_or_default();
1293 let text = format!(
1294 "pool bytes: {pool}\npage size: {size}\npage count: {pages}\n\
1295 free pages: {free}\ncache hits: {}\ncache misses: {}",
1296 cache.hits, cache.misses
1297 );
1298 Ok(Outcome::said("stats", text)
1299 .with("pool_bytes", Json::Int(pool as i64))
1300 .with("page_size", Json::Int(size.parse::<i64>().unwrap_or(0)))
1301 .with("page_count", Json::Int(pages.parse::<i64>().unwrap_or(0)))
1302 .with("free_pages", Json::Int(free.parse::<i64>().unwrap_or(0)))
1303 .with("cache_hits", Json::Int(cache.hits as i64))
1304 .with("cache_misses", Json::Int(cache.misses as i64)))
1305}
1306
1307fn neighbours_asked_for(arguments: &Arguments) -> Result<i64, Failed> {
1317 let k = arguments.integer("k").unwrap_or(10);
1318 if k < 1 {
1319 return Err(Failed::misuse(
1320 "'k' has to be one or more: it is how many rows to return.",
1321 ));
1322 }
1323 Ok(k)
1324}
1325
1326pub fn search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1333 let query_text = arguments.required_text("query")?.to_string();
1334 let name = arguments.required_text("table")?.to_string();
1335 let k = neighbours_asked_for(arguments)?;
1336 let reranked = match arguments.flag("rerank") {
1339 true => format!(" AND question = {} AND k = {k}", quoted_text(&query_text)),
1340 false => String::new(),
1341 };
1342 let sql = format!(
1343 "SELECT rowid, * FROM {0} WHERE {0} MATCH {1}{reranked} ORDER BY rank LIMIT {k}",
1344 quoted(&name),
1345 quoted_text(&query_text)
1346 );
1347 let mut produced = produce(context, "search", &sql, &[], 0)?;
1348 produced.command = "search".to_string();
1349 Ok(produced.with("query", json::text(&query_text)))
1350}
1351
1352pub fn vector_search(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1354 let name = arguments.required_text("table")?.to_string();
1355 let column = arguments.required_text("column")?.to_string();
1356 let numbers = arguments.values("vector");
1357 if numbers.is_empty() {
1358 return Err(Failed::misuse(
1359 "'vector' has to be an array of numbers, one per dimension.",
1360 ));
1361 }
1362 let mut blob = String::from("x'");
1363 for value in &numbers {
1364 let Some(number) = value.integer().map(|whole| whole as f64).or(match value {
1365 Json::Real(real) => Some(*real),
1366 _ => None,
1367 }) else {
1368 return Err(Failed::misuse(
1369 "every element of 'vector' has to be a number.",
1370 ));
1371 };
1372 for byte in (number as f32).to_bits().to_le_bytes() {
1373 blob.push_str(&format!("{byte:02x}"));
1374 }
1375 }
1376 blob.push('\'');
1377 let k = neighbours_asked_for(arguments)?;
1378 let measure = arguments.text("measure").unwrap_or("cos");
1379 let function = match measure {
1380 "cos" => "vector_distance_cos",
1381 "l2" => "vector_distance_l2",
1382 "dot" => "vector_dot",
1383 other => {
1384 return Err(Failed::misuse(format!(
1385 "'{other}' is not a measure. Use cos, l2 or dot."
1386 )))
1387 }
1388 };
1389 let shape = searched_table_shape(context, &name);
1390 let rowid = if shape.integer_key { "" } else { "rowid, " };
1394 let sql = format!(
1395 "SELECT {rowid}*, {function}({1}, {blob}) AS distance FROM {0} \
1396 WHERE {1} IS NOT NULL ORDER BY {function}({1}, {blob}) LIMIT {k}",
1397 quoted(&name),
1398 quoted(&column)
1399 );
1400 let mut produced = produce(context, "vector-search", &sql, &[], 0)?;
1401 let offset = usize::from(!shape.integer_key);
1402 let positions: Vec<usize> = shape.vectors.iter().map(|nth| nth + offset).collect();
1403 for row in &mut produced.rows {
1404 for &nth in &positions {
1405 if let Some(cell) = row.get_mut(nth) {
1406 if let Some(numbers) = vector_numbers(cell) {
1407 *cell = numbers;
1408 }
1409 }
1410 }
1411 }
1412 if !positions.is_empty() {
1413 let names: Vec<String> = produced.columns.iter().map(|c| c.name.clone()).collect();
1414 produced.columns = columns_from(&names, &produced.rows);
1415 produced.text = table(&produced.columns, &produced.rows, &context.null);
1416 }
1417 Ok(produced)
1418}
1419
1420struct SearchedTable {
1422 integer_key: bool,
1424 vectors: Vec<usize>,
1426}
1427
1428fn searched_table_shape(context: &mut Context, name: &str) -> SearchedTable {
1437 let info = context
1438 .shell()
1439 .collect(&format!("PRAGMA table_info({})", quoted(name)))
1440 .map(|(_, rows)| rows)
1441 .unwrap_or_default();
1442 let text_of = |value: Option<&Value<'static>>| match value {
1443 Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).to_ascii_uppercase(),
1444 _ => String::new(),
1445 };
1446 let key_of = |row: &Vec<Value<'static>>| match row.get(5) {
1447 Some(Value::Integer(key)) => *key,
1448 _ => 0,
1449 };
1450 let keys: Vec<&Vec<Value<'static>>> = info.iter().filter(|row| key_of(row) > 0).collect();
1451 let integer_key = matches!(keys.as_slice(), [only] if text_of(only.get(2)) == "INTEGER");
1452 let vectors = info
1453 .iter()
1454 .enumerate()
1455 .filter(|(_, row)| text_of(row.get(2)).starts_with("VECTOR"))
1456 .map(|(nth, _)| nth)
1457 .collect();
1458 SearchedTable {
1459 integer_key,
1460 vectors,
1461 }
1462}
1463
1464fn vector_numbers(cell: &Json) -> Option<Json> {
1472 let hex = cell.get("blob").and_then(Json::text)?;
1473 let bytes: Vec<u8> = hex
1474 .as_bytes()
1475 .chunks(2)
1476 .map(|pair| {
1477 std::str::from_utf8(pair)
1478 .ok()
1479 .and_then(|digits| u8::from_str_radix(digits, 16).ok())
1480 })
1481 .collect::<Option<Vec<u8>>>()?;
1482 if !bytes.len().is_multiple_of(4) {
1483 return None;
1484 }
1485 let numbers = bytes
1486 .chunks_exact(4)
1487 .map(|word| {
1488 let mut four = [0u8; 4];
1489 four.copy_from_slice(word);
1490 Json::Real(f64::from(f32::from_le_bytes(four)))
1491 })
1492 .collect();
1493 Some(Json::Array(numbers))
1494}
1495
1496pub fn capabilities(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1498 let wanted = arguments.text("name");
1499 let rows: Vec<Vec<Json>> = inillucent_driver::CAPABILITIES
1500 .iter()
1501 .filter(|entry| wanted.is_none_or(|name| entry.name == name))
1502 .map(|entry| {
1503 vec![
1504 json::text(entry.name),
1505 json::text(support_name(entry.support)),
1506 json::text(entry.note),
1507 ]
1508 })
1509 .collect();
1510 if rows.is_empty() {
1511 return Err(Failed::said(
1512 Status::NotFound,
1513 format!(
1514 "no capability named \"{}\". An unknown name means no, never yes: a capability \
1515 that was never declared was never checked.",
1516 wanted.unwrap_or_default()
1517 ),
1518 ));
1519 }
1520 let names = vec![
1521 "capability".to_string(),
1522 "support".to_string(),
1523 "note".to_string(),
1524 ];
1525 let columns = columns_from(&names, &rows);
1526 let text = wrapped_notes(&rows);
1532 Ok(Outcome {
1533 command: "capabilities".to_string(),
1534 total: rows.len(),
1535 rows,
1536 columns,
1537 more: false,
1538 changes: 0,
1539 last_insert_rowid: 0,
1540 elapsed_ms: 0.0,
1541 text,
1542 extra: Vec::new(),
1543 })
1544}
1545
1546fn wrapped_notes(rows: &[Vec<Json>]) -> String {
1550 let mut lines = Vec::with_capacity(rows.len() * 3);
1551 for row in rows {
1552 let name = row.first().and_then(Json::text).unwrap_or_default();
1553 let support = row.get(1).and_then(Json::text).unwrap_or_default();
1554 let note = row.get(2).and_then(Json::text).unwrap_or_default();
1555 lines.push(format!("{name:<22} {support}"));
1556 for line in wrap(note, 74) {
1557 lines.push(format!(" {line}"));
1558 }
1559 }
1560 lines.join(
1561 "
1562",
1563 )
1564}
1565
1566fn wrap(text: &str, width: usize) -> Vec<String> {
1575 let mut lines = Vec::new();
1576 let mut current = String::new();
1577 for word in text.split_whitespace() {
1578 if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
1579 lines.push(std::mem::take(&mut current));
1580 }
1581 if !current.is_empty() {
1582 current.push(' ');
1583 }
1584 current.push_str(word);
1585 }
1586 if !current.is_empty() {
1587 lines.push(current);
1588 }
1589 lines
1590}
1591
1592fn support_name(support: Support) -> &'static str {
1596 match support {
1597 Support::Yes => "yes",
1598 Support::Partial => "partial",
1599 Support::No => "no",
1600 }
1601}
1602
1603pub fn functions(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1605 let mut sql = String::from("PRAGMA function_list");
1606 let produced = listing(context, "functions", &sql);
1607 let mut produced = produced?;
1612 if let Some(pattern) = arguments.text("pattern") {
1613 produced.rows.retain(|row| {
1614 row.first()
1615 .and_then(Json::text)
1616 .is_some_and(|name| like_matches(pattern, name))
1617 });
1618 produced.total = produced.rows.len();
1619 produced.text = table(&produced.columns, &produced.rows, &context.null);
1620 }
1621 sql.clear();
1622 Ok(produced)
1623}
1624
1625fn like_matches(pattern: &str, name: &str) -> bool {
1636 let pattern: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
1637 let name: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
1638 let (mut p, mut n) = (0usize, 0usize);
1642 let mut star: Option<(usize, usize)> = None;
1643 while n < name.len() {
1644 match pattern.get(p) {
1645 Some('%') => {
1646 star = Some((p, n));
1647 p += 1;
1648 }
1649 Some(&c) if c == '_' || name.get(n) == Some(&c) => {
1650 p += 1;
1651 n += 1;
1652 }
1653 _ => match star {
1654 Some((star_p, star_n)) => {
1655 p = star_p + 1;
1656 n = star_n + 1;
1657 star = Some((star_p, star_n + 1));
1658 }
1659 None => return false,
1660 },
1661 }
1662 }
1663 pattern
1664 .get(p..)
1665 .is_some_and(|rest| rest.iter().all(|&c| c == '%'))
1666}
1667
1668pub fn migrate(context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
1680 let destination = arguments.required_text("destination")?.to_string();
1681 let source = resolve_source(context, arguments.text("source"))?;
1682 let kind = arguments
1683 .text("kind")
1684 .map(str::to_string)
1685 .unwrap_or_else(|| kind_of_source(&source));
1686 if kind == "postgres" || kind == "mysql" {
1687 return migrate_remote(context, &source, &destination, arguments);
1688 }
1689
1690 let from = context.confine(&source)?;
1691 let to = context.confine(&destination)?;
1692 if !from.exists() {
1693 return Err(Failed::said(
1694 Status::NotFound,
1695 format!("there is no \"{source}\" to migrate from."),
1696 ));
1697 }
1698 if to.exists() {
1699 return Err(Failed::said(
1700 Status::InvalidState,
1701 format!("\"{destination}\" already exists. This tool never overwrites."),
1702 ));
1703 }
1704 match kind.as_str() {
1705 "sqlite" => migrate_sqlite_file(&from, &to),
1706 "index" => Err(Failed::unsupported(
1707 "migrate --kind index",
1708 "the retrieval-index migration runs in inillucent-migrate, which links the retrieval \
1709 engine. Run: inillucent-migrate <source-index-dir> <destination.db>",
1710 )),
1711 other => Err(Failed::misuse(format!(
1712 "'{other}' is not a migration kind. Use sqlite, postgres, mysql or index."
1713 ))),
1714 }
1715}
1716
1717const SOURCE_URL_VARIABLE: &str = "INILLUCENT_SOURCE_URL";
1719
1720fn resolve_source(context: &Context, argument: Option<&str>) -> Result<String, Failed> {
1742 if let Some(source) = argument {
1743 if source != "-" {
1744 return Ok(source.to_string());
1745 }
1746 }
1747 if let Ok(held) = std::env::var(SOURCE_URL_VARIABLE) {
1748 if !held.trim().is_empty() {
1749 return Ok(held);
1750 }
1751 }
1752 if argument == Some("-") {
1753 if context.confined() {
1754 return Err(Failed::said(
1755 Status::InvalidState,
1756 "this surface is confined to a directory with --root, and '-' reads the source \
1757 from standard input, which such a surface does not have to itself. Set \
1758 INILLUCENT_SOURCE_URL instead.",
1759 ));
1760 }
1761 let mut line = String::new();
1762 std::io::stdin()
1763 .read_line(&mut line)
1764 .map_err(|error| Failed::said(Status::Io, format!("standard input: {error}")))?;
1765 let line = line.trim_end_matches(['\r', '\n']).to_string();
1766 if line.is_empty() {
1767 return Err(Failed::misuse(
1768 "standard input held no source. Write the file path or the connection URL on one \
1769 line.",
1770 ));
1771 }
1772 return Ok(line);
1773 }
1774 Err(Failed::misuse(format!(
1775 "migrate needs a source: a database file, or a postgres:// or mysql:// URL. Pass it as \
1776 the first argument, set {SOURCE_URL_VARIABLE}, or pass '-' to read one line from \
1777 standard input."
1778 )))
1779}
1780
1781fn kind_of_source(source: &str) -> String {
1785 match inillucent_remote::ConnectionUrl::parse(source) {
1786 Ok(url) => url.scheme.name().to_string(),
1787 Err(_) => "sqlite".to_string(),
1788 }
1789}
1790
1791fn migrate_remote(
1805 context: &mut Context,
1806 source: &str,
1807 destination: &str,
1808 arguments: &Arguments,
1809) -> Result<Outcome, Failed> {
1810 if context.confined() {
1811 return Err(Failed::said(
1812 Status::InvalidState,
1813 "this surface is confined to a directory with --root, and a migration from a server \
1814 reaches a host and a port rather than a path. Run it from an unconfined command \
1815 line.",
1816 ));
1817 }
1818 let url = inillucent_remote::ConnectionUrl::parse(source)
1819 .map_err(|error| Failed::misuse(error.detail().unwrap_or_else(|| error.message())))?;
1820 let to = context.confine(destination)?;
1821 if to.exists() {
1822 return Err(Failed::said(
1823 Status::InvalidState,
1824 format!("\"{destination}\" already exists. This tool never overwrites."),
1825 ));
1826 }
1827 let mut plan = inillucent_remote::Plan::new(url, &to);
1828 plan.limits = Some(context.limits());
1833 if let Some(batch) = arguments.integer("batch") {
1834 plan.batch = (batch.max(1)) as u64;
1835 }
1836 plan.insecure_plaintext = arguments.flag("insecure-plaintext");
1837 plan.transport().map_err(|error| {
1841 Failed::said(
1842 Status::InvalidState,
1843 error.detail().unwrap_or_else(|| error.message()),
1844 )
1845 })?;
1846 let report = inillucent_remote::migrate::migrate(&plan).map_err(|error| {
1847 Failed::said(
1848 Status::Io,
1849 error.detail().unwrap_or_else(|| error.message()),
1850 )
1851 })?;
1852
1853 let checks: Vec<json::Json> = report
1854 .checks
1855 .iter()
1856 .map(|check| {
1857 json::object(vec![
1858 ("name", json::text(&check.name)),
1859 ("passed", json::Json::Bool(check.passed)),
1860 ("detail", json::text(&check.detail)),
1861 ])
1862 })
1863 .collect();
1864 let tables: Vec<json::Json> = report
1865 .tables
1866 .iter()
1867 .map(|table| {
1868 json::object(vec![
1869 ("source", json::text(&table.source)),
1870 ("destination", json::text(&table.target)),
1871 ("rows", json::Json::Int(table.rows as i64)),
1872 ("digest", json::text(&table.digest)),
1873 ])
1874 })
1875 .collect();
1876 let not_carried: Vec<json::Json> = report
1877 .not_carried
1878 .iter()
1879 .map(|(kind, name)| {
1880 json::object(vec![("kind", json::text(kind)), ("name", json::text(name))])
1881 })
1882 .collect();
1883
1884 let mut text = format!(
1889 "{} -> {}\n{}, {} tables, {} rows\ntransport: {}\n",
1890 report.source,
1891 to.display(),
1892 report.server,
1893 report.tables.len(),
1894 report.rows(),
1895 report.transport
1896 );
1897 for check in &report.checks {
1898 text.push_str(&format!(" {}\n", check.line()));
1899 }
1900 if !report.passed() {
1901 text.push_str(&format!(
1902 "verification failed; nothing was published. The staging file is at {}",
1903 report.staged.display()
1904 ));
1905 return Err(Failed::said(Status::Io, text));
1906 }
1907 text.push_str(&format!("published: {}", to.display()));
1908
1909 Ok(Outcome::said("migrate", text)
1910 .with("destination", json::text(to.to_string_lossy()))
1911 .with("transport", json::text(&report.transport))
1914 .with("source", json::text(&report.source))
1917 .with("server", json::text(&report.server))
1918 .with("rows", json::Json::Int(report.rows() as i64))
1919 .with("tables", json::Json::Array(tables))
1920 .with("checks", json::Json::Array(checks))
1921 .with("notCarried", json::Json::Array(not_carried)))
1922}
1923
1924fn migrate_sqlite_file(from: &std::path::Path, to: &std::path::Path) -> Result<Outcome, Failed> {
1949 let report = inillucent_migrate::sqlite::migrate(from, to)
1952 .map_err(|error| Failed::from_engine(&error))?;
1953 let failures: Vec<String> = report
1954 .failures()
1955 .iter()
1956 .map(|check| format!("{}: {}", check.name, check.detail))
1957 .collect();
1958 let checks = Json::Array(
1959 report
1960 .checks
1961 .iter()
1962 .map(|check| {
1963 json::object(vec![
1964 ("name", json::text(&check.name)),
1965 ("passed", Json::Bool(check.passed)),
1966 ("detail", json::text(&check.detail)),
1967 ])
1968 })
1969 .collect(),
1970 );
1971 if !report.passed() {
1975 return Err(Failed::said(
1976 Status::Corrupt,
1977 format!(
1978 "{} was not published: {}",
1979 to.display(),
1980 failures.join("; ")
1981 ),
1982 ));
1983 }
1984 Ok(Outcome::said(
1985 "migrate",
1986 format!("imported {} into {}", from.display(), to.display()),
1987 )
1988 .with("destination", json::text(to.to_string_lossy()))
1989 .with("checks", checks))
1990}
1991
1992pub fn version(context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
1994 let printed = context.collect_output(".version");
1995 context.shell().failed = false;
1996 context.shell().first_error = None;
1997 let text = format!(
1998 "{}
1999inillucent-cli {}
2000{}",
2001 printed.trim_end(),
2002 env!("CARGO_PKG_VERSION"),
2003 inillucent_driver::version()
2004 );
2005 Ok(Outcome::said("version", text)
2006 .with("cli", json::text(env!("CARGO_PKG_VERSION")))
2007 .with("driver", json::text(inillucent_driver::version())))
2008}
2009
2010pub fn help(_context: &mut Context, arguments: &Arguments) -> Result<Outcome, Failed> {
2012 match arguments.text("topic") {
2013 None => {
2014 let rows: Vec<Vec<Json>> = super::COMMANDS
2015 .iter()
2016 .map(|command| vec![json::text(command.name), json::text(command.summary)])
2017 .collect();
2018 let names = vec!["command".to_string(), "what it does".to_string()];
2019 let columns = columns_from(&names, &rows);
2020 let text = table(&columns, &rows, "");
2021 Ok(Outcome {
2022 command: "help".to_string(),
2023 total: rows.len(),
2024 rows,
2025 columns,
2026 more: false,
2027 changes: 0,
2028 last_insert_rowid: 0,
2029 elapsed_ms: 0.0,
2030 text,
2031 extra: Vec::new(),
2032 })
2033 }
2034 Some(topic) => {
2035 let Some(command) = super::find(topic) else {
2036 return Err(Failed::said(
2037 Status::NotFound,
2038 format!("there is no '{topic}' command. Run 'inillucent help' for the list."),
2039 ));
2040 };
2041 let mut text = format!(
2042 "{}\n\n{}\n\n{}",
2043 command.usage(),
2044 command.summary,
2045 command.detail
2046 );
2047 if !command.params.is_empty() {
2048 text.push_str("\n\nParameters:");
2049 for param in command.params {
2050 text.push_str(&format!(
2051 "\n {:<12} {}{}",
2052 param.name,
2053 if param.required { "(required) " } else { "" },
2054 param.description
2055 ));
2056 }
2057 }
2058 Ok(Outcome::said("help", text))
2059 }
2060 }
2061}
2062
2063fn front_end_only(name: &'static str) -> Failed {
2072 Failed::misuse(format!(
2073 "'{name}' is run by the inillucent binary itself and cannot be dispatched here."
2074 ))
2075}
2076
2077pub fn shell_placeholder(
2079 _context: &mut Context,
2080 _arguments: &Arguments,
2081) -> Result<Outcome, Failed> {
2082 Err(front_end_only("shell"))
2083}
2084
2085pub fn mcp_placeholder(_context: &mut Context, _arguments: &Arguments) -> Result<Outcome, Failed> {
2087 Err(front_end_only("mcp"))
2088}
2089
2090#[cfg(test)]
2091mod source_tests {
2092 use super::*;
2093 use crate::shell::Shell;
2094
2095 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2109 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2110 LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
2111 }
2112
2113 fn context(root: Option<std::path::PathBuf>) -> Context {
2117 Context::for_test(
2118 Shell::open(":memory:").expect("a memory database opens"),
2119 root,
2120 )
2121 }
2122
2123 #[test]
2125 fn an_argument_is_the_source() {
2126 let context = context(None);
2127 let held = resolve_source(&context, Some("postgres://user@host/db"))
2128 .expect("the argument is accepted");
2129 assert_eq!(held, "postgres://user@host/db");
2130 }
2131
2132 #[test]
2137 fn the_environment_supplies_a_source_that_was_not_an_argument() {
2138 let _held = env_guard();
2139 let context = context(None);
2140 std::env::set_var(SOURCE_URL_VARIABLE, "postgres://user:secret@host/db");
2141 let held = resolve_source(&context, None).expect("the variable is read");
2142 std::env::remove_var(SOURCE_URL_VARIABLE);
2143 assert_eq!(held, "postgres://user:secret@host/db");
2144 }
2145
2146 #[test]
2148 fn no_source_anywhere_is_refused_by_name() {
2149 let _held = env_guard();
2150 let context = context(None);
2151 std::env::remove_var(SOURCE_URL_VARIABLE);
2152 let error = resolve_source(&context, None).expect_err("there is no source");
2153 let said = format!("{error:?}");
2154 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2155 assert!(said.contains("standard input"), "{said}");
2156 }
2157
2158 #[test]
2164 fn a_confined_surface_refuses_to_read_standard_input() {
2165 let _held = env_guard();
2166 let context = context(Some(std::env::temp_dir()));
2167 std::env::remove_var(SOURCE_URL_VARIABLE);
2168 let error = resolve_source(&context, Some("-")).expect_err("a confined surface refuses");
2169 let said = format!("{error:?}");
2170 assert!(said.contains("--root"), "{said}");
2171 assert!(said.contains(SOURCE_URL_VARIABLE), "{said}");
2172 }
2173
2174 #[test]
2176 fn export_refuses_table_and_sql_together() {
2177 let mut arguments = Arguments::default();
2178 arguments.set("table", crate::json::text("expected"));
2179 arguments.set("sql", crate::json::text("SELECT 'other' AS v"));
2180 let failure = export(&mut context(None), &arguments)
2181 .expect_err("export must require one data source");
2182 assert!(failure.message.contains("not both"), "{}", failure.message);
2183 }
2184
2185 #[test]
2190 fn the_environment_wins_over_reading_standard_input() {
2191 let _held = env_guard();
2192 let context = context(None);
2193 std::env::set_var(SOURCE_URL_VARIABLE, "mysql://user@host/db");
2194 let held = resolve_source(&context, Some("-")).expect("the variable is read");
2195 std::env::remove_var(SOURCE_URL_VARIABLE);
2196 assert_eq!(held, "mysql://user@host/db");
2197 }
2198}