use crate::shell::Shell;
use inillucent_value::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Operation {
Create,
Update,
Insert,
Remove,
List,
Extract,
}
struct Request {
operation: Operation,
file: Option<String>,
directory: Option<String>,
verbose: bool,
dry_run: bool,
paths: Vec<String>,
}
pub fn archive(shell: &mut Shell, arguments: &[&str]) {
let request = match parse(arguments) {
Ok(request) => request,
Err(message) => {
shell.complain(&format!("Error: {message}"));
return;
}
};
let outcome = match request.operation {
Operation::List => list(shell, &request),
Operation::Create | Operation::Update | Operation::Insert => add(shell, &request),
Operation::Remove => remove(shell, &request),
Operation::Extract => extract(shell, &request),
};
if let Err(message) = outcome {
shell.complain(&format!("Error: {message}"));
}
}
fn parse(arguments: &[&str]) -> Result<Request, String> {
let mut operation: Option<Operation> = None;
let mut request = Request {
operation: Operation::List,
file: None,
directory: None,
verbose: false,
dry_run: false,
paths: Vec::new(),
};
let mut at = 0usize;
while at < arguments.len() {
let word = arguments.get(at).copied().unwrap_or("");
at = at.saturating_add(1);
let chosen = match word {
"-c" | "--create" => Some(Operation::Create),
"-u" | "--update" => Some(Operation::Update),
"-i" | "--insert" => Some(Operation::Insert),
"-r" | "--remove" => Some(Operation::Remove),
"-t" | "--list" => Some(Operation::List),
"-x" | "--extract" => Some(Operation::Extract),
_ => None,
};
if let Some(chosen) = chosen {
if operation.is_some() {
return Err(
"only one of --create --update --insert --remove --list --extract".to_string(),
);
}
operation = Some(chosen);
continue;
}
match word {
"-v" | "--verbose" => request.verbose = true,
"-n" | "--dryrun" => request.dry_run = true,
"-f" | "--file" | "-a" | "--append" => {
request.file = arguments.get(at).map(|held| held.to_string());
at = at.saturating_add(1);
}
"-C" | "--directory" => {
request.directory = arguments.get(at).map(|held| held.to_string());
at = at.saturating_add(1);
}
_ if word.starts_with('-') && !word.starts_with("--") => {
for letter in word.chars().skip(1) {
match letter {
'c' => operation = Some(Operation::Create),
'u' => operation = Some(Operation::Update),
'i' => operation = Some(Operation::Insert),
'r' => operation = Some(Operation::Remove),
't' => operation = Some(Operation::List),
'x' => operation = Some(Operation::Extract),
'v' => request.verbose = true,
'n' => request.dry_run = true,
'f' | 'a' => {
request.file = arguments.get(at).map(|held| held.to_string());
at = at.saturating_add(1);
}
'C' => {
request.directory = arguments.get(at).map(|held| held.to_string());
at = at.saturating_add(1);
}
other => return Err(format!("unknown option: -{other}")),
}
}
}
_ => request.paths.push(word.to_string()),
}
}
request.operation = operation.unwrap_or(Operation::List);
Ok(request)
}
fn open_archive(shell: &mut Shell, request: &Request) -> Result<String, String> {
let Some(file) = &request.file else {
return Ok("sqlar".to_string());
};
let quoted = file.replace('\'', "''").replace('\\', "/");
shell
.connection()
.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS temp.zip_archive USING zipfile('{quoted}')"
))
.map_err(|error| error.message().to_string())?;
Ok("temp.zip_archive".to_string())
}
fn ensure_sqlar(shell: &mut Shell) -> Result<(), String> {
shell
.connection()
.execute_batch(
"CREATE TABLE IF NOT EXISTS sqlar(\n \
name TEXT PRIMARY KEY, -- name of the file\n \
mode INT, -- access permissions\n \
mtime INT, -- last modification time\n \
sz INT, -- original file size\n \
data BLOB -- compressed content\n)",
)
.map_err(|error| error.message().to_string())
}
fn list(shell: &mut Shell, request: &Request) -> Result<(), String> {
let table = open_archive(shell, request)?;
let sql = if request.verbose {
format!("SELECT name, mode, sz, mtime FROM {table} ORDER BY name")
} else {
format!("SELECT name FROM {table} ORDER BY name")
};
let (_, rows) = shell.collect(&sql).map_err(|held| held.message.clone())?;
for row in rows {
if !request.verbose {
shell.say(&text_of(row.first()));
continue;
}
let mode = integer_of(row.get(1));
let size = integer_of(row.get(2));
let when = stamp(integer_of(row.get(3)));
shell.say(&format!(
"{} {size:>10} {when} {}",
mode_text(mode),
text_of(row.first())
));
}
Ok(())
}
fn add(shell: &mut Shell, request: &Request) -> Result<(), String> {
let table = open_archive(shell, request)?;
if request.dry_run {
for path in &request.paths {
shell.say(&format!("would add {path}"));
}
return Ok(());
}
if request.file.is_none() {
if request.operation == Operation::Create {
shell
.connection()
.execute_batch("DROP TABLE IF EXISTS sqlar")
.map_err(|error| error.message().to_string())?;
}
ensure_sqlar(shell)?;
}
let base = request.directory.clone();
let mut found: Vec<(String, std::path::PathBuf)> = Vec::new();
for path in &request.paths {
let on_disk = match &base {
Some(directory) => std::path::PathBuf::from(directory).join(path),
None => std::path::PathBuf::from(path),
};
collect(&on_disk, path, &mut found);
}
for (name, on_disk) in found {
let Ok(found) = std::fs::symlink_metadata(&on_disk) else {
return Err(format!("cannot stat file: {name}"));
};
let mtime = found
.modified()
.ok()
.and_then(|held| held.duration_since(std::time::UNIX_EPOCH).ok())
.map(|held| held.as_secs() as i64)
.unwrap_or(0);
let (mode, size, data) = if found.is_dir() {
(0o40_777i64, 0i64, None)
} else {
let bytes = std::fs::read(&on_disk).map_err(|held| held.to_string())?;
let mode = if found.permissions().readonly() {
0o100_444
} else {
0o100_666
};
(mode, bytes.len() as i64, Some(bytes))
};
if request.verbose {
shell.say(&name);
}
write_member(shell, &table, &name, mode, mtime, size, data)?;
}
Ok(())
}
fn write_member(
shell: &mut Shell,
table: &str,
name: &str,
mode: i64,
mtime: i64,
size: i64,
data: Option<Vec<u8>>,
) -> Result<(), String> {
let sql = if table == "sqlar" {
format!(
"INSERT OR REPLACE INTO {table}(name, mode, mtime, sz, data) \
VALUES (?1, ?2, ?3, ?4, sqlar_compress(?5))"
)
} else {
format!(
"INSERT OR REPLACE INTO {table}(name, mode, mtime, sz, data) \
VALUES (?1, ?2, ?3, ?4, ?5)"
)
};
let connection = shell.connection();
let mut statement = connection
.prepare(&sql)
.map_err(|error| error.message().to_string())?;
statement
.bind_text(1, name)
.map_err(|error| error.message().to_string())?;
for (index, value) in [(2u32, mode), (3, mtime), (4, size)] {
statement
.bind_integer(index, value)
.map_err(|error| error.message().to_string())?;
}
match data {
Some(bytes) => statement
.bind_blob(5, &bytes)
.map_err(|error| error.message().to_string())?,
None => statement
.bind_null(5)
.map_err(|error| error.message().to_string())?,
}
while statement
.step()
.map_err(|error| error.message().to_string())?
{}
Ok(())
}
fn collect(on_disk: &std::path::Path, shown: &str, into: &mut Vec<(String, std::path::PathBuf)>) {
into.push((shown.to_string(), on_disk.to_path_buf()));
let Ok(listing) = std::fs::read_dir(on_disk) else {
return;
};
let mut found: Vec<(String, std::path::PathBuf)> = listing
.filter_map(|held| held.ok())
.map(|held| (held.file_name().to_string_lossy().into_owned(), held.path()))
.collect();
found.sort_by(|left, right| left.0.cmp(&right.0));
for (name, path) in found {
collect(&path, &format!("{shown}/{name}"), into);
}
}
fn remove(shell: &mut Shell, request: &Request) -> Result<(), String> {
let table = open_archive(shell, request)?;
for path in &request.paths {
if request.dry_run {
shell.say(&format!("would remove {path}"));
continue;
}
if request.verbose {
shell.say(path);
}
let quoted = path.replace('\'', "''");
shell
.connection()
.execute_batch(&format!(
"DELETE FROM {table} WHERE name = '{quoted}' OR name GLOB '{quoted}/*'"
))
.map_err(|error| error.message().to_string())?;
}
Ok(())
}
fn extract(shell: &mut Shell, request: &Request) -> Result<(), String> {
let table = open_archive(shell, request)?;
let column = if table == "sqlar" {
"sqlar_uncompress(data, sz)"
} else {
"data"
};
let (_, rows) = shell
.collect(&format!(
"SELECT name, mode, sz, {column} FROM {table} ORDER BY name"
))
.map_err(|held| held.message.clone())?;
let base = request.directory.clone().unwrap_or_else(|| ".".to_string());
for row in rows {
let name = text_of(row.first());
if !request.paths.is_empty()
&& !request
.paths
.iter()
.any(|held| name == *held || name.starts_with(&format!("{held}/")))
{
continue;
}
let target = std::path::PathBuf::from(&base).join(&name);
if request.dry_run {
shell.say(&format!("would extract {name}"));
continue;
}
if request.verbose {
shell.say(&name);
}
let mode = integer_of(row.get(1));
if mode & 0o40_000 != 0 {
std::fs::create_dir_all(&target).map_err(|held| held.to_string())?;
continue;
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|held| held.to_string())?;
}
let bytes = match row.get(3) {
Some(Value::Blob(blob)) => blob.raw().to_vec(),
Some(Value::Text(text)) => text.utf8_bytes().into_owned(),
_ => Vec::new(),
};
std::fs::write(&target, &bytes).map_err(|held| held.to_string())?;
}
Ok(())
}
fn mode_text(mode: i64) -> String {
let kind = if mode & 0o40_000 != 0 { 'd' } else { '-' };
let mut out = String::with_capacity(10);
out.push(kind);
for shift in [6, 3, 0] {
let bits = (mode >> shift) & 7;
out.push(if bits & 4 != 0 { 'r' } else { '-' });
out.push(if bits & 2 != 0 { 'w' } else { '-' });
out.push(if bits & 1 != 0 { 'x' } else { '-' });
}
out
}
fn stamp(epoch: i64) -> String {
let days = epoch.div_euclid(86_400);
let rest = epoch.rem_euclid(86_400);
let civil = inillucent_scalar::datetime::civil_of_unix_day(days);
let (year, month, day) = (civil.year, civil.month, civil.day);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}",
rest / 3_600,
(rest % 3_600) / 60,
rest % 60
)
}
fn text_of(value: Option<&Value<'static>>) -> String {
match value {
Some(Value::Text(text)) => String::from_utf8_lossy(&text.utf8_bytes()).into_owned(),
Some(Value::Blob(blob)) => String::from_utf8_lossy(blob.raw()).into_owned(),
Some(Value::Integer(number)) => number.to_string(),
_ => String::new(),
}
}
fn integer_of(value: Option<&Value<'static>>) -> i64 {
value.and_then(Value::as_integer).unwrap_or(0)
}