Initial commit
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{blob_summary, format_count};
|
||||
use crate::error::Error;
|
||||
use crate::types::ObjectType;
|
||||
|
||||
pub fn write_list(
|
||||
conn: &Connection,
|
||||
ty: ObjectType,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), Error> {
|
||||
let table = crate::sync::table_name(ty);
|
||||
let total: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
|
||||
let total = total as u64;
|
||||
write_header(out, ty, total, limit, offset)?;
|
||||
if total == 0 {
|
||||
writeln!(out, "(no {} in archive)", ty.token())?;
|
||||
return Ok(());
|
||||
}
|
||||
let names = Names::load(conn, ty)?;
|
||||
let mut written = 0u64;
|
||||
match ty {
|
||||
ObjectType::Identity => write_identities(conn, limit, offset, out, &mut written)?,
|
||||
ObjectType::Email => write_emails(conn, &names, limit, offset, out, &mut written)?,
|
||||
ObjectType::SieveScript => write_sieves(conn, limit, offset, out, &mut written)?,
|
||||
ObjectType::AddressBook => write_address_books(conn, limit, offset, out, &mut written)?,
|
||||
ObjectType::ContactCard => {
|
||||
write_contact_cards(conn, &names, limit, offset, out, &mut written)?
|
||||
}
|
||||
ObjectType::Calendar => write_calendars(conn, limit, offset, out, &mut written)?,
|
||||
ObjectType::CalendarEvent => {
|
||||
write_calendar_events(conn, &names, limit, offset, out, &mut written)?
|
||||
}
|
||||
ObjectType::ParticipantIdentity => {
|
||||
write_participant_identities(conn, limit, offset, out, &mut written)?
|
||||
}
|
||||
ObjectType::Mailbox | ObjectType::FileNode => unreachable!("tree types do not list"),
|
||||
}
|
||||
if let Some(lim) = limit {
|
||||
let next_offset = (offset as u64) + written;
|
||||
if next_offset < total {
|
||||
writeln!(
|
||||
out,
|
||||
"... ({} more; --offset {} --limit {} to continue)",
|
||||
format_count(total - next_offset),
|
||||
next_offset,
|
||||
lim
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_header(
|
||||
out: &mut impl Write,
|
||||
ty: ObjectType,
|
||||
total: u64,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
) -> Result<(), Error> {
|
||||
match limit {
|
||||
None if offset == 0 => writeln!(out, "{} ({})", ty.jmap_name(), format_count(total))?,
|
||||
None => writeln!(
|
||||
out,
|
||||
"{} ({}, skipping first {})",
|
||||
ty.jmap_name(),
|
||||
format_count(total),
|
||||
format_count(offset as u64)
|
||||
)?,
|
||||
Some(lim) => writeln!(
|
||||
out,
|
||||
"{} ({} total, showing up to {} from offset {})",
|
||||
ty.jmap_name(),
|
||||
format_count(total),
|
||||
format_count(lim as u64),
|
||||
format_count(offset as u64)
|
||||
)?,
|
||||
}
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct Names {
|
||||
mailboxes: HashMap<i64, String>,
|
||||
address_books: HashMap<i64, String>,
|
||||
calendars: HashMap<i64, String>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub(crate) fn load(conn: &Connection, ty: ObjectType) -> Result<Self, Error> {
|
||||
let mailboxes = if matches!(ty, ObjectType::Email) {
|
||||
load_name_map(conn, "SELECT id, name FROM mailboxes")?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let address_books = if matches!(ty, ObjectType::ContactCard) {
|
||||
load_name_map(conn, "SELECT id, name FROM address_books")?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let calendars = if matches!(ty, ObjectType::CalendarEvent) {
|
||||
load_name_map(conn, "SELECT id, name FROM calendars")?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
Ok(Names {
|
||||
mailboxes,
|
||||
address_books,
|
||||
calendars,
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, kind: ObjectType, ids: &[i64]) -> String {
|
||||
let map = match kind {
|
||||
ObjectType::Mailbox => &self.mailboxes,
|
||||
ObjectType::AddressBook => &self.address_books,
|
||||
ObjectType::Calendar => &self.calendars,
|
||||
_ => return format!("{ids:?}"),
|
||||
};
|
||||
let parts: Vec<String> = ids
|
||||
.iter()
|
||||
.map(|id| match map.get(id) {
|
||||
Some(name) => format!("{name:?}"),
|
||||
None => format!("#{id} (missing)"),
|
||||
})
|
||||
.collect();
|
||||
format!("[{}]", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_name_map(conn: &Connection, sql: &str) -> Result<HashMap<i64, String>, Error> {
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||
let mut out = HashMap::new();
|
||||
for row in rows {
|
||||
let (id, name) = row?;
|
||||
out.insert(id, name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn limit_clause(limit: Option<usize>, offset: usize) -> String {
|
||||
match limit {
|
||||
Some(n) => format!(" LIMIT {n} OFFSET {offset}"),
|
||||
None if offset > 0 => format!(" LIMIT -1 OFFSET {offset}"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_local_ids(text: &str) -> Vec<i64> {
|
||||
serde_json::from_str::<Value>(text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.as_array()
|
||||
.map(|arr| arr.iter().filter_map(|x| x.as_i64()).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn json_pretty_or_raw(text: &str) -> String {
|
||||
match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_owned()),
|
||||
Err(_) => text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn keywords_summary(text: &str) -> String {
|
||||
let ids = serde_json::from_str::<Vec<String>>(text).unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
"(none)".to_owned()
|
||||
} else {
|
||||
format!("[{}]", ids.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_or_null(s: Option<String>) -> String {
|
||||
s.unwrap_or_else(|| "(null)".to_owned())
|
||||
}
|
||||
|
||||
fn bool_str(b: bool) -> String {
|
||||
(if b { "true" } else { "false" }).to_owned()
|
||||
}
|
||||
|
||||
fn write_record(out: &mut impl Write, title: &str, fields: &[(&str, String)]) -> Result<(), Error> {
|
||||
writeln!(out, "{title}")?;
|
||||
let max_label = fields.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
|
||||
let pad = " ".repeat(max_label + 4);
|
||||
for (label, value) in fields {
|
||||
for (i, line) in value.split('\n').enumerate() {
|
||||
if i == 0 {
|
||||
writeln!(out, " {label:<max_label$} {line}")?;
|
||||
} else {
|
||||
writeln!(out, "{pad}{line}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_identities(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, name, email, reply_to, bcc, text_signature, html_signature
|
||||
FROM identities ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let fields = vec![
|
||||
("name", row.get::<_, String>(1)?),
|
||||
("email", row.get::<_, String>(2)?),
|
||||
("reply_to", opt_or_null(row.get(3)?)),
|
||||
("bcc", opt_or_null(row.get(4)?)),
|
||||
("text_signature", row.get::<_, String>(5)?),
|
||||
("html_signature", row.get::<_, String>(6)?),
|
||||
];
|
||||
write_record(out, &format!("identity #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_emails(
|
||||
conn: &Connection,
|
||||
names: &Names,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, blob_id, received_at, mailbox_ids, keywords, message_match
|
||||
FROM emails ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let blob_id: i64 = row.get(1)?;
|
||||
let mailbox_ids: String = row.get(3)?;
|
||||
let keywords: String = row.get(4)?;
|
||||
let message_match: String = row.get(5)?;
|
||||
let locals = parse_local_ids(&mailbox_ids);
|
||||
let fields = vec![
|
||||
("blob", blob_summary(conn, blob_id)?),
|
||||
("received_at", row.get::<_, String>(2)?),
|
||||
("mailboxes", names.render(ObjectType::Mailbox, &locals)),
|
||||
("keywords", keywords_summary(&keywords)),
|
||||
("message_match", json_pretty_or_raw(&message_match)),
|
||||
];
|
||||
write_record(out, &format!("email #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_sieves(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, name, is_active, blob_id FROM sieve_scripts ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let name: Option<String> = row.get(1)?;
|
||||
let active: i64 = row.get(2)?;
|
||||
let blob_id: i64 = row.get(3)?;
|
||||
let fields = vec![
|
||||
("name", opt_or_null(name)),
|
||||
("is_active", bool_str(active != 0)),
|
||||
("blob", blob_summary(conn, blob_id)?),
|
||||
];
|
||||
write_record(out, &format!("sievescript #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_address_books(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, name, description, sort_order, is_default, is_subscribed
|
||||
FROM address_books ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let fields = vec![
|
||||
("name", row.get::<_, String>(1)?),
|
||||
("description", opt_or_null(row.get(2)?)),
|
||||
("sort_order", row.get::<_, i64>(3)?.to_string()),
|
||||
("is_default", bool_str(row.get::<_, i64>(4)? != 0)),
|
||||
("is_subscribed", bool_str(row.get::<_, i64>(5)? != 0)),
|
||||
];
|
||||
write_record(out, &format!("addressbook #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_contact_cards(
|
||||
conn: &Connection,
|
||||
names: &Names,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, uid, address_book_ids, data FROM contact_cards ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let uid: String = row.get(1)?;
|
||||
let ab_ids: String = row.get(2)?;
|
||||
let data: String = row.get(3)?;
|
||||
let locals = parse_local_ids(&ab_ids);
|
||||
let fields = vec![
|
||||
("uid", uid),
|
||||
(
|
||||
"address_books",
|
||||
names.render(ObjectType::AddressBook, &locals),
|
||||
),
|
||||
("data", redact_blobs_in_json(&data)),
|
||||
];
|
||||
write_record(out, &format!("contactcard #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_calendars(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, name, description, color, sort_order, is_subscribed, is_visible, is_default,
|
||||
include_in_availability, default_alerts_with_time, default_alerts_without_time,
|
||||
time_zone
|
||||
FROM calendars ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let fields = vec![
|
||||
("name", row.get::<_, String>(1)?),
|
||||
("description", opt_or_null(row.get(2)?)),
|
||||
("color", opt_or_null(row.get(3)?)),
|
||||
("sort_order", row.get::<_, i64>(4)?.to_string()),
|
||||
("is_subscribed", bool_str(row.get::<_, i64>(5)? != 0)),
|
||||
("is_visible", bool_str(row.get::<_, i64>(6)? != 0)),
|
||||
("is_default", bool_str(row.get::<_, i64>(7)? != 0)),
|
||||
("include_in_availability", row.get::<_, String>(8)?),
|
||||
("default_alerts_with_time", opt_or_null(row.get(9)?)),
|
||||
("default_alerts_without_time", opt_or_null(row.get(10)?)),
|
||||
("time_zone", opt_or_null(row.get(11)?)),
|
||||
];
|
||||
write_record(out, &format!("calendar #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_calendar_events(
|
||||
conn: &Connection,
|
||||
names: &Names,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, calendar_ids, is_draft, use_default_alerts, data
|
||||
FROM calendar_events ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let cal_ids: String = row.get(1)?;
|
||||
let data: String = row.get(4)?;
|
||||
let locals = parse_local_ids(&cal_ids);
|
||||
let fields = vec![
|
||||
("calendars", names.render(ObjectType::Calendar, &locals)),
|
||||
("is_draft", bool_str(row.get::<_, i64>(2)? != 0)),
|
||||
("use_default_alerts", bool_str(row.get::<_, i64>(3)? != 0)),
|
||||
("data", redact_blobs_in_json(&data)),
|
||||
];
|
||||
write_record(out, &format!("calendarevent #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_participant_identities(
|
||||
conn: &Connection,
|
||||
limit: Option<usize>,
|
||||
offset: usize,
|
||||
out: &mut impl Write,
|
||||
written: &mut u64,
|
||||
) -> Result<(), Error> {
|
||||
let sql = format!(
|
||||
"SELECT id, name, calendar_address, is_default
|
||||
FROM participant_identities ORDER BY id{}",
|
||||
limit_clause(limit, offset)
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let fields = vec![
|
||||
("name", row.get::<_, String>(1)?),
|
||||
("calendar_address", row.get::<_, String>(2)?),
|
||||
("is_default", bool_str(row.get::<_, i64>(3)? != 0)),
|
||||
];
|
||||
write_record(out, &format!("participantidentity #{id}"), &fields)?;
|
||||
*written += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redact_blobs_in_json(text: &str) -> String {
|
||||
let mut value = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return text.to_owned(),
|
||||
};
|
||||
redact_blobs(&mut value);
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_owned())
|
||||
}
|
||||
|
||||
fn redact_blobs(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(bid) = map.get("@blob").and_then(|v| v.as_i64()) {
|
||||
map.clear();
|
||||
map.insert(
|
||||
"@blob".to_owned(),
|
||||
Value::String(format!("local #{bid} (omitted)")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for v in map.values_mut() {
|
||||
redact_blobs(v);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
redact_blobs(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{blobs, init};
|
||||
use rusqlite::params;
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
fn add_mailbox(c: &Connection, name: &str) -> i64 {
|
||||
c.execute("INSERT INTO mailboxes (name) VALUES (?1)", params![name])
|
||||
.unwrap();
|
||||
c.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_prints_none_message() {
|
||||
let c = mem();
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::Identity, None, 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("Identity (0)"));
|
||||
assert!(s.contains("(no identity in archive)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_record_uses_aligned_fields_and_redacts_nothing() {
|
||||
let c = mem();
|
||||
c.execute(
|
||||
"INSERT INTO identities (name, email, text_signature) VALUES ('Alice', '[email protected]', 'best,A')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::Identity, None, 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("identity #1"));
|
||||
assert!(s.contains(" name Alice"), "got:\n{s}");
|
||||
assert!(s.contains(" email [email protected]"));
|
||||
assert!(s.contains(" bcc (null)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_resolves_mailbox_names_and_summarises_blob() {
|
||||
let c = mem();
|
||||
let inbox = add_mailbox(&c, "Inbox");
|
||||
let other = add_mailbox(&c, "Personal");
|
||||
let blob = blobs::intern_blob(&c, b"From: [email protected]\r\nSubject: hi\r\n\r\nhello").unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
|
||||
VALUES (?1, '2024-01-01T00:00:00Z', ?2, ?3)",
|
||||
params![
|
||||
blob,
|
||||
format!("[{inbox},{other}]"),
|
||||
"[\"$seen\",\"Important\"]"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::Email, None, 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("email #1"));
|
||||
assert!(s.contains("blob "));
|
||||
assert!(s.contains("blake3="));
|
||||
assert!(s.contains("mailboxes "));
|
||||
assert!(s.contains("\"Inbox\""), "got:\n{s}");
|
||||
assert!(s.contains("\"Personal\""));
|
||||
assert!(s.contains("keywords "));
|
||||
assert!(s.contains("[$seen, Important]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_card_redacts_blob_sentinel_in_data() {
|
||||
let c = mem();
|
||||
c.execute("INSERT INTO address_books (name) VALUES ('Personal')", [])
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
"urn:uuid:1",
|
||||
"[1]",
|
||||
r#"{"name":{"full":"Alice"},"photos":{"p":{"@blob":17}}}"#
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::ContactCard, None, 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("contactcard #1"));
|
||||
assert!(s.contains("\"Personal\""));
|
||||
assert!(s.contains("local #17 (omitted)"));
|
||||
assert!(!s.contains("\"@blob\": 17"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_writes_continuation_hint_when_more_remain() {
|
||||
let c = mem();
|
||||
for i in 0..5 {
|
||||
c.execute(
|
||||
"INSERT INTO identities (name, email) VALUES (?1, ?2)",
|
||||
params![format!("u{i}"), format!("u{i}@x.test")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::Identity, Some(2), 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("Identity (5 total, showing up to 2 from offset 0)"));
|
||||
assert!(s.contains("identity #1"));
|
||||
assert!(s.contains("identity #2"));
|
||||
assert!(!s.contains("identity #3"));
|
||||
assert!(s.contains("... (3 more; --offset 2 --limit 2 to continue)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_at_end_does_not_print_continuation() {
|
||||
let c = mem();
|
||||
for i in 0..3 {
|
||||
c.execute(
|
||||
"INSERT INTO identities (name, email) VALUES (?1, ?2)",
|
||||
params![format!("u{i}"), format!("u{i}@x.test")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
write_list(&c, ObjectType::Identity, Some(5), 0, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(!s.contains("more;"), "got:\n{s}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::db::init;
|
||||
use crate::error::Error;
|
||||
use crate::types::ObjectType;
|
||||
|
||||
pub mod list;
|
||||
pub mod tree;
|
||||
|
||||
pub struct InspectConfig {
|
||||
pub archive: PathBuf,
|
||||
pub target: Option<ObjectType>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
pub fn run(config: InspectConfig) -> Result<(), Error> {
|
||||
let conn = init::open(&config.archive)?;
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
dispatch(&conn, &config, &mut out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch(conn: &Connection, cfg: &InspectConfig, out: &mut impl Write) -> Result<(), Error> {
|
||||
match cfg.target {
|
||||
None => write_summary(conn, &cfg.archive, out),
|
||||
Some(ObjectType::Mailbox) => {
|
||||
warn_if_paged(out, cfg, "mailbox")?;
|
||||
tree::write_mailboxes(conn, out)
|
||||
}
|
||||
Some(ObjectType::FileNode) => {
|
||||
warn_if_paged(out, cfg, "filenode")?;
|
||||
tree::write_file_nodes(conn, out)
|
||||
}
|
||||
Some(ty) => list::write_list(conn, ty, cfg.limit, cfg.offset, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_if_paged(out: &mut impl Write, cfg: &InspectConfig, token: &str) -> Result<(), Error> {
|
||||
if cfg.limit.is_some() || cfg.offset > 0 {
|
||||
writeln!(
|
||||
out,
|
||||
"note: --limit/--offset are ignored for {token} (tree view always shows the full hierarchy)"
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_summary(conn: &Connection, archive: &Path, out: &mut impl Write) -> Result<(), Error> {
|
||||
writeln!(out, "Archive: {}", archive.display())?;
|
||||
match read_source(conn)? {
|
||||
Some(src) => {
|
||||
let name = src.account_name.unwrap_or_else(|| "(unnamed)".to_owned());
|
||||
writeln!(
|
||||
out,
|
||||
"Source: {} {} (account {} / {})",
|
||||
src.kind, src.session_url, src.account_id, name
|
||||
)?;
|
||||
}
|
||||
None => writeln!(out, "Source: (no source recorded)")?,
|
||||
}
|
||||
writeln!(out)?;
|
||||
let counts = read_type_counts(conn)?;
|
||||
let label_width = ObjectType::ALL
|
||||
.iter()
|
||||
.map(|t| t.token().len())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let count_width = counts
|
||||
.values()
|
||||
.map(|n| format_count(*n).len())
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max("blobs".len());
|
||||
for ty in ObjectType::ALL {
|
||||
let n = counts.get(&ty).copied().unwrap_or(0);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<lw$} {:>cw$}",
|
||||
ty.token(),
|
||||
format_count(n),
|
||||
lw = label_width,
|
||||
cw = count_width
|
||||
)?;
|
||||
}
|
||||
let (blob_count, blob_bytes) = read_blob_totals(conn)?;
|
||||
writeln!(out)?;
|
||||
writeln!(
|
||||
out,
|
||||
" {:<lw$} {:>cw$} ({})",
|
||||
"blobs",
|
||||
format_count(blob_count),
|
||||
format_bytes(blob_bytes),
|
||||
lw = label_width,
|
||||
cw = count_width
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SourceRow {
|
||||
kind: String,
|
||||
session_url: String,
|
||||
account_id: String,
|
||||
account_name: Option<String>,
|
||||
}
|
||||
|
||||
fn read_source(conn: &Connection) -> Result<Option<SourceRow>, Error> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT kind, session_url, account_id, account_name FROM sources LIMIT 1",
|
||||
[],
|
||||
|r| {
|
||||
Ok(SourceRow {
|
||||
kind: r.get(0)?,
|
||||
session_url: r.get(1)?,
|
||||
account_id: r.get(2)?,
|
||||
account_name: r.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn read_type_counts(conn: &Connection) -> Result<HashMap<ObjectType, u64>, Error> {
|
||||
let mut out = HashMap::new();
|
||||
for ty in ObjectType::ALL {
|
||||
let sql = format!("SELECT COUNT(*) FROM {}", crate::sync::table_name(ty));
|
||||
let n: i64 = conn.query_row(&sql, [], |r| r.get(0))?;
|
||||
out.insert(ty, n as u64);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_blob_totals(conn: &Connection) -> Result<(u64, u64), Error> {
|
||||
let (count, bytes): (i64, i64) = conn.query_row(
|
||||
"SELECT COUNT(*), COALESCE(SUM(length(data)), 0) FROM blobs",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok((count as u64, bytes.max(0) as u64))
|
||||
}
|
||||
|
||||
pub(crate) fn format_count(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut out = String::with_capacity(len + len / 3);
|
||||
for (i, ch) in bytes.iter().enumerate() {
|
||||
if i > 0 && (len - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(*ch as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn format_bytes(n: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
if n < 1024 {
|
||||
return format!("{n} B");
|
||||
}
|
||||
let mut value = n as f64;
|
||||
let mut i = 0;
|
||||
while value >= 1024.0 && i + 1 < UNITS.len() {
|
||||
value /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
format!("{value:.1} {}", UNITS[i])
|
||||
}
|
||||
|
||||
pub(crate) fn blob_summary(conn: &Connection, blob_id: i64) -> Result<String, Error> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT length(data), substr(lower(hex(hash)), 1, 12) FROM blobs WHERE id = ?1",
|
||||
params![blob_id],
|
||||
|r| {
|
||||
let len: i64 = r.get(0)?;
|
||||
let hex: String = r.get(1)?;
|
||||
Ok((len, hex))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
match row {
|
||||
Some((len, hex)) => Ok(format!("{} blake3={hex}", format_bytes(len.max(0) as u64))),
|
||||
None => Ok(format!("(missing blob #{blob_id})")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_count_inserts_grouping_commas() {
|
||||
assert_eq!(format_count(0), "0");
|
||||
assert_eq!(format_count(42), "42");
|
||||
assert_eq!(format_count(999), "999");
|
||||
assert_eq!(format_count(1_000), "1,000");
|
||||
assert_eq!(format_count(12_345), "12,345");
|
||||
assert_eq!(format_count(1_234_567_890), "1,234,567,890");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_picks_appropriate_unit() {
|
||||
assert_eq!(format_bytes(0), "0 B");
|
||||
assert_eq!(format_bytes(512), "512 B");
|
||||
assert_eq!(format_bytes(1024), "1.0 KB");
|
||||
assert_eq!(format_bytes(1_536), "1.5 KB");
|
||||
assert_eq!(format_bytes(10 * 1024 * 1024), "10.0 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_summary_reports_size_and_short_hash() {
|
||||
let c = mem();
|
||||
let id = crate::db::blobs::intern_blob(&c, b"hello world").unwrap();
|
||||
let s = blob_summary(&c, id).unwrap();
|
||||
assert!(s.starts_with("11 B blake3="), "got {s}");
|
||||
assert!(s.len() > "11 B blake3=".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_summary_handles_missing_id() {
|
||||
let c = mem();
|
||||
let s = blob_summary(&c, 9_999).unwrap();
|
||||
assert_eq!(s, "(missing blob #9999)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_lists_every_type_and_blob_totals() {
|
||||
let c = mem();
|
||||
crate::db::sources::upsert_source(
|
||||
&c,
|
||||
&crate::db::sources::SourceKey {
|
||||
kind: "jmap".to_owned(),
|
||||
session_url: "https://example.test/jmap".to_owned(),
|
||||
account_id: "u-1".to_owned(),
|
||||
},
|
||||
Some("[email protected]"),
|
||||
"[email protected]",
|
||||
)
|
||||
.unwrap();
|
||||
c.execute("INSERT INTO mailboxes (name) VALUES (?1)", params!["Inbox"])
|
||||
.unwrap();
|
||||
crate::db::blobs::intern_blob(&c, b"some bytes").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
write_summary(&c, Path::new("test.sqlite"), &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("Archive: test.sqlite"));
|
||||
assert!(s.contains("Source: jmap https://example.test/jmap"));
|
||||
assert!(s.contains("[email protected]"));
|
||||
for ty in ObjectType::ALL {
|
||||
assert!(s.contains(ty.token()), "missing {} in:\n{s}", ty.token());
|
||||
}
|
||||
assert!(s.contains("blobs"));
|
||||
assert!(s.contains("10 B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_without_source_reports_none() {
|
||||
let c = mem();
|
||||
let mut buf = Vec::new();
|
||||
write_summary(&c, Path::new("x.sqlite"), &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("(no source recorded)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{blob_summary, format_count};
|
||||
use crate::error::Error;
|
||||
|
||||
struct MailboxNode {
|
||||
id: i64,
|
||||
parent_id: Option<i64>,
|
||||
name: String,
|
||||
role: Option<String>,
|
||||
sort_order: i64,
|
||||
msg_count: u64,
|
||||
}
|
||||
|
||||
pub fn write_mailboxes(conn: &Connection, out: &mut impl Write) -> Result<(), Error> {
|
||||
let nodes = load_mailboxes(conn)?;
|
||||
writeln!(out, "Mailboxes ({})", format_count(nodes.len() as u64))?;
|
||||
if nodes.is_empty() {
|
||||
writeln!(out, "(none)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let by_id: HashMap<i64, &MailboxNode> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let mut by_parent: HashMap<Option<i64>, Vec<i64>> = HashMap::new();
|
||||
for n in &nodes {
|
||||
by_parent.entry(n.parent_id).or_default().push(n.id);
|
||||
}
|
||||
for ids in by_parent.values_mut() {
|
||||
ids.sort_by(|a, b| {
|
||||
let na = by_id[a];
|
||||
let nb = by_id[b];
|
||||
na.sort_order
|
||||
.cmp(&nb.sort_order)
|
||||
.then_with(|| na.name.cmp(&nb.name))
|
||||
});
|
||||
}
|
||||
let roots: Vec<i64> = by_parent.get(&None).cloned().unwrap_or_default();
|
||||
let last = roots.len().saturating_sub(1);
|
||||
for (i, id) in roots.iter().enumerate() {
|
||||
write_mailbox(out, &by_id, &by_parent, *id, "", i == last)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_mailbox(
|
||||
out: &mut impl Write,
|
||||
by_id: &HashMap<i64, &MailboxNode>,
|
||||
by_parent: &HashMap<Option<i64>, Vec<i64>>,
|
||||
id: i64,
|
||||
prefix: &str,
|
||||
is_last: bool,
|
||||
) -> Result<(), Error> {
|
||||
let node = by_id[&id];
|
||||
let connector = if is_last { "└── " } else { "├── " };
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
if let Some(role) = &node.role {
|
||||
tags.push(format!("role={role}"));
|
||||
}
|
||||
tags.push(format!("{} msgs", format_count(node.msg_count)));
|
||||
writeln!(
|
||||
out,
|
||||
"{prefix}{connector}{} [{}]",
|
||||
node.name,
|
||||
tags.join(", ")
|
||||
)?;
|
||||
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||
if let Some(children) = by_parent.get(&Some(id)) {
|
||||
let last = children.len() - 1;
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
write_mailbox(out, by_id, by_parent, *child, &child_prefix, i == last)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_mailboxes(conn: &Connection) -> Result<Vec<MailboxNode>, Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.id, m.parent_id, m.name, m.role, m.sort_order,
|
||||
(SELECT COUNT(*) FROM emails e
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(e.mailbox_ids) j
|
||||
WHERE CAST(j.value AS INTEGER) = m.id))
|
||||
FROM mailboxes m",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(MailboxNode {
|
||||
id: r.get(0)?,
|
||||
parent_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
role: r.get(3)?,
|
||||
sort_order: r.get(4)?,
|
||||
msg_count: r.get::<_, i64>(5)? as u64,
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct FileNodeRow {
|
||||
id: i64,
|
||||
parent_id: Option<i64>,
|
||||
node_type: String,
|
||||
blob_id: Option<i64>,
|
||||
name: String,
|
||||
media_type: Option<String>,
|
||||
target: Option<String>,
|
||||
child_count: u64,
|
||||
}
|
||||
|
||||
pub fn write_file_nodes(conn: &Connection, out: &mut impl Write) -> Result<(), Error> {
|
||||
let nodes = load_file_nodes(conn)?;
|
||||
writeln!(out, "File nodes ({})", format_count(nodes.len() as u64))?;
|
||||
if nodes.is_empty() {
|
||||
writeln!(out, "(none)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let by_id: HashMap<i64, &FileNodeRow> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let mut by_parent: HashMap<Option<i64>, Vec<i64>> = HashMap::new();
|
||||
for n in &nodes {
|
||||
by_parent.entry(n.parent_id).or_default().push(n.id);
|
||||
}
|
||||
for ids in by_parent.values_mut() {
|
||||
ids.sort_by(|a, b| {
|
||||
let na = by_id[a];
|
||||
let nb = by_id[b];
|
||||
(na.node_type != "directory")
|
||||
.cmp(&(nb.node_type != "directory"))
|
||||
.then_with(|| na.name.cmp(&nb.name))
|
||||
});
|
||||
}
|
||||
let roots: Vec<i64> = by_parent.get(&None).cloned().unwrap_or_default();
|
||||
let last = roots.len().saturating_sub(1);
|
||||
for (i, id) in roots.iter().enumerate() {
|
||||
write_file_node(conn, out, &by_id, &by_parent, *id, "", i == last)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_node(
|
||||
conn: &Connection,
|
||||
out: &mut impl Write,
|
||||
by_id: &HashMap<i64, &FileNodeRow>,
|
||||
by_parent: &HashMap<Option<i64>, Vec<i64>>,
|
||||
id: i64,
|
||||
prefix: &str,
|
||||
is_last: bool,
|
||||
) -> Result<(), Error> {
|
||||
let node = by_id[&id];
|
||||
let connector = if is_last { "└── " } else { "├── " };
|
||||
let display = match node.node_type.as_str() {
|
||||
"directory" => format!("{}/", node.name),
|
||||
_ => node.name.clone(),
|
||||
};
|
||||
let detail = match node.node_type.as_str() {
|
||||
"directory" => format!("({} children)", format_count(node.child_count)),
|
||||
"symlink" => {
|
||||
let target = node.target.as_deref().unwrap_or("[]");
|
||||
format!("symlink -> {target}")
|
||||
}
|
||||
_ => {
|
||||
let media = node
|
||||
.media_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
match node.blob_id {
|
||||
Some(bid) => format!("{media}, {}", blob_summary(conn, bid)?),
|
||||
None => format!("{media}, (no blob)"),
|
||||
}
|
||||
}
|
||||
};
|
||||
writeln!(out, "{prefix}{connector}{display} {detail}")?;
|
||||
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||
if let Some(children) = by_parent.get(&Some(id)) {
|
||||
let last = children.len() - 1;
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
write_file_node(
|
||||
conn,
|
||||
out,
|
||||
by_id,
|
||||
by_parent,
|
||||
*child,
|
||||
&child_prefix,
|
||||
i == last,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_file_nodes(conn: &Connection) -> Result<Vec<FileNodeRow>, Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, f.parent_id, f.node_type, f.blob_id, f.name, f.media_type, f.target,
|
||||
(SELECT COUNT(*) FROM file_nodes c WHERE c.parent_id = f.id)
|
||||
FROM file_nodes f",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(FileNodeRow {
|
||||
id: r.get(0)?,
|
||||
parent_id: r.get(1)?,
|
||||
node_type: r.get(2)?,
|
||||
blob_id: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
media_type: r.get(5)?,
|
||||
target: r.get(6)?,
|
||||
child_count: r.get::<_, i64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{blobs, init};
|
||||
use rusqlite::params;
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
fn insert_mailbox(
|
||||
c: &Connection,
|
||||
name: &str,
|
||||
parent: Option<i64>,
|
||||
role: Option<&str>,
|
||||
sort: i64,
|
||||
) -> i64 {
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES (?1, ?2, ?3, ?4, 1)",
|
||||
params![name, parent, role, sort],
|
||||
)
|
||||
.unwrap();
|
||||
c.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_email_in(c: &Connection, mailbox_id: i64) {
|
||||
let blob = blobs::intern_blob(c, format!("seed-{mailbox_id}").as_bytes()).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
|
||||
VALUES (?1, '2024-01-01T00:00:00Z', ?2, '[]')",
|
||||
params![blob, format!("[{mailbox_id}]")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_tree_draws_box_characters_and_msg_counts() {
|
||||
let c = mem();
|
||||
let inbox = insert_mailbox(&c, "Inbox", None, Some("inbox"), 0);
|
||||
let _archived = insert_mailbox(&c, "Archived", Some(inbox), None, 0);
|
||||
let _y2024 = insert_mailbox(&c, "2024", Some(inbox), None, 1);
|
||||
let _sent = insert_mailbox(&c, "Sent", None, Some("sent"), 1);
|
||||
insert_email_in(&c, inbox);
|
||||
insert_email_in(&c, inbox);
|
||||
let mut buf = Vec::new();
|
||||
write_mailboxes(&c, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("Mailboxes (4)"), "header missing: {s}");
|
||||
assert!(s.contains("├── Inbox [role=inbox, 2 msgs]"), "got:\n{s}");
|
||||
assert!(s.contains("│ ├── Archived"), "got:\n{s}");
|
||||
assert!(s.contains("│ └── 2024"), "got:\n{s}");
|
||||
assert!(s.contains("└── Sent [role=sent, 0 msgs]"), "got:\n{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_tree_empty_says_none() {
|
||||
let c = mem();
|
||||
let mut buf = Vec::new();
|
||||
write_mailboxes(&c, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("Mailboxes (0)"));
|
||||
assert!(s.contains("(none)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_node_tree_distinguishes_dirs_files_symlinks() {
|
||||
let c = mem();
|
||||
let docs: i64 = {
|
||||
c.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, name, created)
|
||||
VALUES (NULL, 'directory', 'Documents', '2024-01-01T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.last_insert_rowid()
|
||||
};
|
||||
let blob = blobs::intern_blob(&c, b"hello world").unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, blob_id, name, media_type, created)
|
||||
VALUES (?1, 'file', ?2, 'note.txt', 'text/plain', '2024-01-01T00:00:00Z')",
|
||||
params![docs, blob],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, target, name, created)
|
||||
VALUES (?1, 'symlink', '[\"..\",\"etc\"]', 'shortcut', '2024-01-01T00:00:00Z')",
|
||||
params![docs],
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
write_file_nodes(&c, &mut buf).unwrap();
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("File nodes (3)"));
|
||||
assert!(s.contains("Documents/"));
|
||||
assert!(s.contains("(2 children)"));
|
||||
assert!(s.contains("note.txt"));
|
||||
assert!(s.contains("text/plain, 11 B blake3="));
|
||||
assert!(s.contains("symlink -> [\"..\",\"etc\"]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user