#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(clippy::indexing_slicing)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![cfg_attr(
test,
allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)
)]
use std::path::PathBuf;
use std::process::ExitCode;
use inillucent_cli::command::{self, Arguments, Command, Context, Failed, Kind, OpenMode};
use inillucent_cli::json::{self, Json};
use inillucent_cli::mcp;
#[global_allocator]
static ALLOCATOR: inillucent_alloc::Pooled = inillucent_alloc::Pooled;
struct Invocation {
verb: Option<String>,
rest: Vec<String>,
database: String,
database_was_named: bool,
json: bool,
readonly: bool,
root: Option<PathBuf>,
limit: usize,
null: String,
}
fn main() -> ExitCode {
inillucent_cli::on_a_sized_stack(run)
}
fn run() -> ExitCode {
let arguments: Vec<String> = std::env::args().skip(1).collect();
if let Some(topic) = help_topic(&arguments) {
return dispatch_help(topic);
}
match arguments.first().map(String::as_str) {
None | Some("--help") | Some("-h") | Some("help") if arguments.len() <= 1 => {
print_overview();
return ExitCode::SUCCESS;
}
Some("--version") | Some("-V") => {
println!("inillucent {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
_ => {}
}
let invocation = match split(&arguments) {
Ok(invocation) => invocation,
Err(message) => {
eprintln!("{message}");
return ExitCode::from(2);
}
};
let Some(verb) = invocation.verb.clone() else {
print_overview();
return ExitCode::from(2);
};
let Some(command) = command::find(&verb) else {
if names_a_database(&verb) {
return shell_like(&arguments);
}
return unknown_verb(&verb);
};
match command.name {
"shell" => {
let mut handed: Vec<String> = Vec::new();
if invocation.database_was_named {
handed.push(invocation.database.clone());
}
handed.extend(invocation.rest.iter().cloned());
shell_like(&handed)
}
"mcp" => serve(&invocation),
"create" if invocation.database_was_named => {
eprintln!("create takes its database path as its argument and does not accept --db.");
ExitCode::from(2)
}
_ => dispatch(command, &invocation),
}
}
fn names_a_database(word: &str) -> bool {
if word == ":memory:" || word.starts_with("file:") {
return true;
}
if std::path::Path::new(word).exists() {
return true;
}
if word.contains('/') || word.contains('\\') {
return true;
}
let mut letters = word.chars();
if letters
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& letters.next() == Some(':')
{
return true;
}
std::path::Path::new(word).extension().is_some()
}
fn unknown_verb(word: &str) -> ExitCode {
eprintln!("inillucent: '{word}' is not a command, and it does not name a database file.");
let nearest = nearest_commands(word);
if !nearest.is_empty() {
eprintln!(" Did you mean: {}?", nearest.join(", "));
}
eprintln!(
" Run 'inillucent help' for the {} commands there are.",
command::COMMANDS.len()
);
eprintln!(
" To open a file of that name as a database, write it as a path: inillucent ./{word}"
);
ExitCode::from(2)
}
fn nearest_commands(word: &str) -> Vec<&'static str> {
let lowered = word.to_ascii_lowercase();
let mut scored: Vec<(usize, &'static str)> = command::COMMANDS
.iter()
.filter_map(|candidate| {
if !lowered.is_empty() && candidate.name.starts_with(&lowered) {
return Some((0, candidate.name));
}
let gap = distance(&lowered, candidate.name);
(gap <= 2).then_some((gap, candidate.name))
})
.collect();
scored.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(right.1)));
scored.truncate(3);
scored.into_iter().map(|(_, name)| name).collect()
}
fn distance(from: &str, to: &str) -> usize {
let target: Vec<char> = to.chars().collect();
let mut previous: Vec<usize> = (0..=target.len()).collect();
for (row, wrote) in from.chars().enumerate() {
let mut current: Vec<usize> = Vec::with_capacity(target.len().saturating_add(1));
current.push(row.saturating_add(1));
for (column, expected) in target.iter().enumerate() {
let substitution = previous
.get(column)
.copied()
.unwrap_or(usize::MAX)
.saturating_add(usize::from(wrote != *expected));
let deletion = previous
.get(column.saturating_add(1))
.copied()
.unwrap_or(usize::MAX)
.saturating_add(1);
let insertion = current
.get(column)
.copied()
.unwrap_or(usize::MAX)
.saturating_add(1);
current.push(substitution.min(deletion).min(insertion));
}
previous = current;
}
previous.last().copied().unwrap_or(0)
}
fn help_topic(arguments: &[String]) -> Option<&str> {
let topic = arguments
.iter()
.find(|argument| command::find(argument).is_some())?;
arguments
.iter()
.any(|argument| matches!(argument.as_str(), "--help" | "-h"))
.then_some(topic)
}
fn dispatch_help(topic: &str) -> ExitCode {
let Some(command) = command::find(topic) else {
eprintln!("there is no '{topic}' command. Run 'inillucent help' for the list.");
return ExitCode::from(2);
};
let invocation = Invocation {
verb: Some("help".to_string()),
rest: vec![command.name.to_string()],
database: ":memory:".to_string(),
database_was_named: false,
json: false,
readonly: false,
root: None,
limit: 200,
null: String::new(),
};
let Some(help) = command::find("help") else {
return ExitCode::from(2);
};
dispatch(help, &invocation)
}
fn split(arguments: &[String]) -> Result<Invocation, String> {
let mut invocation = Invocation {
verb: None,
rest: Vec::new(),
database: std::env::var("INILLUCENT_DB").unwrap_or_else(|_| ":memory:".to_string()),
database_was_named: false,
json: false,
readonly: false,
root: None,
limit: 200,
null: String::new(),
};
let mut walk = arguments.iter();
while let Some(argument) = walk.next() {
match argument.as_str() {
"--db" | "-d" => {
invocation.database_was_named = true;
invocation.database = walk
.next()
.cloned()
.ok_or_else(|| "--db needs a path.".to_string())?;
}
"--json" => invocation.json = true,
"--readonly" => invocation.readonly = true,
"--root" => {
let named = walk
.next()
.cloned()
.ok_or_else(|| "--root needs a directory.".to_string())?;
invocation.root = Some(PathBuf::from(named));
}
"--limit" => {
let value = walk
.next()
.cloned()
.ok_or_else(|| "--limit needs a number.".to_string())?;
invocation.limit = value
.parse()
.map_err(|_| format!("--limit wants a number, not '{value}'."))?;
}
"--null" => {
invocation.null = walk
.next()
.cloned()
.ok_or_else(|| "--null needs a string.".to_string())?;
}
"--output" => {
let value = walk
.next()
.cloned()
.ok_or_else(|| "--output needs 'text' or 'json'.".to_string())?;
invocation.json = match value.as_str() {
"json" => true,
"text" => false,
other => return Err(format!("--output wants text or json, not '{other}'.")),
};
}
_ if invocation.verb.is_none() && !argument.starts_with('-') => {
invocation.verb = Some(argument.clone());
}
other => invocation.rest.push(other.to_string()),
}
}
Ok(invocation)
}
fn dispatch(command: &'static Command, invocation: &Invocation) -> ExitCode {
let arguments = match collect(command, &invocation.rest) {
Ok(arguments) => arguments,
Err(message) => {
eprintln!("{message}\nUsage: {}", command.usage());
return ExitCode::from(2);
}
};
let database = if command.name == "create" || command.name == "migrate" {
":memory:"
} else {
&invocation.database
};
let mut context = match Context::open_for(
database,
OpenMode::of(invocation.readonly),
invocation.root.clone(),
command.writes.may_create(),
) {
Ok(context) => context,
Err(failure) => return report(&failure, invocation.json, command.name),
};
context.limit = invocation.limit;
context.null = invocation.null.clone();
inillucent_cli::interrupt::stop_on_ctrl_c(context.cancel_flag());
let recovery = context.recovery();
let strays = context.stray_log_segments().to_vec();
match command::run(command, &mut context, &arguments) {
Ok(produced) => {
let produced = produced.with_recovery(&recovery, &strays);
let shown = match invocation.json {
true => produced.to_json().pretty(0),
false => produced.text.clone(),
};
if !shown.is_empty() {
println!("{shown}");
}
ExitCode::SUCCESS
}
Err(failure) => report(&failure, invocation.json, command.name),
}
}
fn report(failure: &Failed, as_json: bool, command: &str) -> ExitCode {
match as_json {
true => println!("{}", failure.to_json(command).pretty(0)),
false => eprintln!("{}", failure.to_text()),
}
ExitCode::from(failure.exit_code() as u8)
}
fn collect(command: &'static Command, rest: &[String]) -> Result<Arguments, String> {
let mut arguments = Arguments::default();
let mut positional_taken = false;
let mut walk = rest.iter().peekable();
while let Some(word) = walk.next() {
let Some(name) = word.strip_prefix("--") else {
let Some(param) = command.positional() else {
return Err(format!(
"'{}' takes no unnamed argument, and got '{word}'.",
command.name
));
};
if positional_taken {
return Err(format!("'{}' takes one unnamed argument.", command.name));
}
positional_taken = true;
arguments.set(param.name, coerce(param.kind, word)?);
continue;
};
let Some(param) = command
.param(&name.replace('-', "_"))
.or(command.param(name))
else {
return Err(format!("'{}' has no --{name} option.", command.name));
};
if param.kind == Kind::Boolean {
let explicit = walk
.peek()
.and_then(|next| parse_boolean(next))
.inspect(|_value| {
walk.next();
})
.unwrap_or(true);
arguments.set(param.name, Json::Bool(explicit));
continue;
}
let Some(value) = walk.next() else {
return Err(format!("--{name} needs a value."));
};
arguments.set(param.name, coerce(param.kind, value)?);
}
Ok(arguments)
}
fn coerce(kind: Kind, word: &str) -> Result<Json, String> {
match kind {
Kind::Text => Ok(json::text(word)),
Kind::Integer => word
.parse::<i64>()
.map(Json::Int)
.map_err(|_| format!("'{word}' is not a whole number.")),
Kind::Boolean => parse_boolean(word)
.map(Json::Bool)
.ok_or_else(|| format!("'{word}' is not true or false.")),
Kind::Values => json::parse(word)
.map_err(|why| format!("'{word}' is not a JSON array: {why}"))
.and_then(|value| match value {
Json::Array(_) => Ok(value),
_ => Err(format!("'{word}' is not a JSON array.")),
}),
}
}
fn parse_boolean(word: &str) -> Option<bool> {
match word.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Some(true),
"false" | "no" | "off" | "0" => Some(false),
_ => None,
}
}
fn serve(invocation: &Invocation) -> ExitCode {
let settings = mcp::Settings {
database: invocation.database.clone(),
readonly: invocation.readonly,
root: invocation.root.clone(),
limit: invocation.limit,
..mcp::Settings::default()
};
let input = std::io::BufReader::new(std::io::stdin());
let mut output = std::io::stdout();
match mcp::serve(settings, input, &mut output) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("inillucent-mcp: {message}");
ExitCode::FAILURE
}
}
}
fn shell_like(arguments: &[String]) -> ExitCode {
let mut path = std::env::current_exe().unwrap_or_default();
path.set_file_name(match cfg!(windows) {
true => "inillucent-shell.exe",
false => "inillucent-shell",
});
if !path.is_file() {
eprintln!(
"inillucent: the interactive shell lives in a separate binary and it is not beside \
this one.\n looked for: {}\n Everything the shell does is also reachable with: \
inillucent run \"<input>\"",
path.display()
);
return ExitCode::FAILURE;
}
match std::process::Command::new(&path).args(arguments).status() {
Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8),
Err(error) => {
eprintln!("inillucent: could not start {}: {error}", path.display());
ExitCode::FAILURE
}
}
}
fn print_overview() {
println!(
"inillucent {} - an embedded SQL database with search built in",
env!("CARGO_PKG_VERSION")
);
println!();
println!("Usage: inillucent <command> [arguments] [options]");
println!();
println!("Commands:");
let width = command::COMMANDS
.iter()
.map(|command| command.name.len())
.max()
.unwrap_or(0);
for command in command::COMMANDS {
let padding = " ".repeat(width.saturating_sub(command.name.len()));
println!(" {}{padding} {}", command.name, command.summary);
}
println!();
println!("Options, which may go anywhere on the line:");
for line in [
" -d, --db PATH the database to open (or $INILLUCENT_DB; :memory: by default)",
" --json print the whole result object instead of a table",
" --output WHICH text or json (--json means --output json)",
" --readonly refuse every statement that would change something",
" --root DIR refuse every path that resolves outside DIR (links followed)",
" --limit N how many rows to hand back (default 200; 0 for all)",
" --null TEXT what to print where a value is null",
" -V, --version print the version and stop",
" -h, --help print this",
] {
println!("{line}");
}
println!();
println!("'inillucent help <command>' explains one command and every option it takes.");
println!(
"'inillucent <file> [SQL...]' runs the sqlite3-shaped shell, as 'sqlite3 <file>' does."
);
println!();
println!("Exit codes: 0 ok, 1 failed, 2 bad command line, 3 the engine has not built that.");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mistyped_command_does_not_name_a_database() {
for word in ["bogusverb", "qeury", "descrbe", "quer", ""] {
assert!(!names_a_database(word), "{word} was read as a file name");
}
}
#[test]
fn a_database_is_recognised_by_how_it_is_written() {
for word in [
":memory:",
"app.rdb",
"./app",
"data/app",
"C:\\tmp\\app",
"C:app",
"file:app.rdb?mode=ro",
] {
assert!(names_a_database(word), "{word} was not read as a file name");
}
}
#[test]
fn the_nearest_command_is_suggested() {
assert!(nearest_commands("qeury").contains(&"query"));
assert!(nearest_commands("descr").contains(&"describe"));
assert!(nearest_commands("expor").contains(&"export"));
}
#[test]
fn a_word_close_to_nothing_suggests_nothing() {
assert!(nearest_commands("zzzzzzzzzzzz").is_empty());
}
#[test]
fn there_are_never_more_than_three_suggestions() {
assert!(nearest_commands("e").len() <= 3);
}
#[test]
fn the_distance_counts_single_character_edits() {
assert_eq!(distance("query", "query"), 0);
assert_eq!(distance("quer", "query"), 1);
assert_eq!(distance("qeury", "query"), 2);
assert_eq!(distance("", "query"), 5);
}
}