use crate::render::Mode;
use crate::shell::Shell;
const UNIT_SEPARATOR: char = '\u{1f}';
const RECORD_SEPARATOR: char = '\u{1e}';
struct Reading<'a> {
path: &'a str,
table: &'a str,
separator: char,
row_separator: char,
quoted: bool,
skip: usize,
}
fn reading<'a>(shell: &mut Shell, arguments: &[&'a str]) -> Option<Reading<'a>> {
let mut separator = if shell.layout.mode == Mode::Csv {
','
} else {
shell.layout.separator.chars().next().unwrap_or('|')
};
let mut quoted = shell.layout.mode == Mode::Csv;
let mut row_separator = '\n';
let mut skip = 0usize;
let mut positional: Vec<&'a str> = Vec::new();
let mut index = 0;
while index < arguments.len() {
let Some(word) = arguments.get(index).copied() else {
break;
};
index += 1;
let value = if matches!(word, "--colsep" | "--rowsep" | "--skip") {
let Some(taken) = arguments.get(index).copied() else {
shell.complain(&format!("Error: {word} wants a value"));
return None;
};
index += 1;
taken
} else {
""
};
match word {
"--csv" => {
separator = ',';
quoted = true;
row_separator = '\n';
}
"--ascii" => {
separator = UNIT_SEPARATOR;
quoted = false;
row_separator = RECORD_SEPARATOR;
}
"--colsep" | "--rowsep" => {
let Some(character) = value.chars().next() else {
shell.complain(&format!("Error: {word} wants a character"));
return None;
};
if word == "--colsep" {
separator = character;
} else {
row_separator = character;
}
}
"--skip" => match value.parse::<usize>() {
Ok(count) => skip = count,
Err(_) => {
shell.complain("Error: --skip wants a number of rows");
return None;
}
},
"-v" => {}
other if other.starts_with('-') => {
shell.complain(&format!("Error: .import does not take {other}"));
return None;
}
other => positional.push(other),
}
}
let (Some(path), Some(table)) = (positional.first(), positional.get(1)) else {
shell.complain("Error: .import requires a file name and a table name");
return None;
};
Some(Reading {
path,
table,
separator,
row_separator,
quoted,
skip,
})
}
fn read_text(path: &str) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
String::from_utf8(bytes).map_err(|error| {
let at = error.utf8_error().valid_up_to();
let byte = error
.as_bytes()
.get(at)
.map(|held| format!("0x{held:02X}"))
.unwrap_or_else(|| "the end of the file".to_string());
format!(
"it is not valid UTF-8; byte {at} is {byte}, which does not begin a character. This reads UTF-8 text only; convert the file first, with `iconv -f cp1252 -t utf-8` or the encoding it was written in."
)
})
}
pub fn import(shell: &mut Shell, arguments: &[&str]) {
let Some(reading) = reading(shell, arguments) else {
return;
};
let (named, table) = (reading.path, reading.table);
let Some(path) = crate::dot::confine_path(shell, named) else {
return;
};
let text = match read_text(&path) {
Ok(text) => text,
Err(why) => {
shell.complain(&format!("Error: cannot read \"{named}\": {why}"));
return;
}
};
let mut rows = parse(
&text,
reading.separator,
reading.quoted,
reading.row_separator,
);
if reading.skip > 0 {
rows.drain(..reading.skip.min(rows.len()));
}
if rows.is_empty() {
return;
}
let exists = table_exists(shell, table);
if !exists {
let header = rows.remove(0);
let columns: Vec<String> = header.iter().map(|name| quote_identifier(name)).collect();
let create = format!(
"CREATE TABLE {} ({})",
quote_identifier(table),
columns.join(",")
);
if let Err(message) = shell.execute(&create) {
shell.complain(&format!("Error: {message}"));
return;
}
}
insert(shell, table, &rows, &path);
}
fn insert(shell: &mut Shell, table: &str, rows: &[Vec<String>], path: &str) {
let width = rows.first().map_or(0, Vec::len);
if width == 0 {
return;
}
let marks: Vec<&str> = std::iter::repeat_n("?", width).collect();
let sql = format!(
"INSERT INTO {} VALUES({})",
quote_identifier(table),
marks.join(",")
);
if let Err(message) = shell.execute("BEGIN") {
shell.complain(&format!("Error: {message}"));
return;
}
let failure = fill(&shell.connection(), &sql, rows);
match failure {
None => {
if let Err(message) = shell.execute("COMMIT") {
shell.complain(&format!("Error: {message}"));
}
}
Some((line, message)) => {
shell.complain(&format!("{path}:{line}: {message}"));
let _ = shell.execute("ROLLBACK");
}
}
}
fn fill(
connection: &inillucent_engine::connect::Connection<'_>,
sql: &str,
rows: &[Vec<String>],
) -> Option<(usize, String)> {
let mut statement = match connection.prepare(sql) {
Ok(statement) => statement,
Err(error) => return Some((0, error.message().to_string())),
};
for (index, row) in rows.iter().enumerate() {
statement.clear_bindings();
for (position, field) in row.iter().enumerate() {
if let Err(error) = statement.bind_text(position as u32 + 1, field) {
return Some((index + 1, error.message().to_string()));
}
}
loop {
match statement.step() {
Ok(true) => continue,
Ok(false) => break,
Err(error) => return Some((index + 1, error.message().to_string())),
}
}
}
None
}
fn table_exists(shell: &Shell, table: &str) -> bool {
let sql = format!(
"SELECT count(*) FROM sqlite_master WHERE type IN ('table','view') AND name = '{}'",
table.replace('\'', "''")
);
shell.scalar(&sql).is_some_and(|count| count != "0")
}
fn parse(text: &str, separator: char, quoted: bool, row_separator: char) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut row = Vec::new();
let mut field = String::new();
let mut inside = false;
let mut characters = text.chars().peekable();
let mut anything = false;
while let Some(character) = characters.next() {
if inside {
if character == '"' {
if characters.peek() == Some(&'"') {
characters.next();
field.push('"');
} else {
inside = false;
}
} else {
field.push(character);
}
continue;
}
match character {
'"' if quoted && field.is_empty() => {
inside = true;
anything = true;
}
_ if character == separator => {
row.push(core::mem::take(&mut field));
anything = true;
}
'\r' if row_separator == '\n' => continue,
_ if character == row_separator => {
if anything || !field.is_empty() || !row.is_empty() {
row.push(core::mem::take(&mut field));
rows.push(core::mem::take(&mut row));
}
anything = false;
}
other => {
field.push(other);
anything = true;
}
}
}
if anything || !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
fn quote_identifier(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_split_on_the_separator() {
let rows = parse("a|b\nc|d\n", '|', false, '\n');
assert_eq!(rows, vec![vec!["a", "b"], vec!["c", "d"]]);
}
#[test]
fn a_quoted_field_holds_anything() {
let rows = parse("\"a,b\",c\n\"line\none\",d\n", ',', true, '\n');
assert_eq!(rows, vec![vec!["a,b", "c"], vec!["line\none", "d"]]);
}
#[test]
fn a_doubled_quote_is_one_quote() {
let rows = parse("\"say \"\"hi\"\"\",x\n", ',', true, '\n');
assert_eq!(rows, vec![vec!["say \"hi\"", "x"]]);
}
#[test]
fn a_missing_final_newline_is_still_a_row() {
let rows = parse("a|b", '|', false, '\n');
assert_eq!(rows, vec![vec!["a", "b"]]);
}
#[test]
fn ascii_separators_leave_a_newline_as_data() {
let text = "a\u{1f}line\none\u{1e}c\u{1f}d\u{1e}";
let rows = parse(text, UNIT_SEPARATOR, false, RECORD_SEPARATOR);
assert_eq!(rows, vec![vec!["a", "line\none"], vec!["c", "d"]]);
}
}