history.rs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. //! SQLite-backed store for message history, chats (with autoreply flag) and contacts.
  2. use std::path::Path;
  3. use anyhow::{Context, Result};
  4. use rusqlite::{Connection, OptionalExtension, Row, params};
  5. const TABLE_MESSAGES: &str = "messages";
  6. const TABLE_CHATS: &str = "chats";
  7. const TABLE_CONTACTS: &str = "contacts";
  8. const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
  9. /// Default `chats.last_timestamp` for chats that have no message yet.
  10. const DEFAULT_LAST_TIMESTAMP: i64 = 0;
  11. /// Default autoreply state of a chat that was never toggled.
  12. const DEFAULT_AUTOREPLY: bool = false;
  13. /// Separator between the user part and the server part of a WhatsApp jid.
  14. const JID_SEPARATOR: char = '@';
  15. /// A message persisted in the history.
  16. #[derive(Debug, Clone, PartialEq)]
  17. pub struct StoredMessage {
  18. pub message_id: String,
  19. pub chat_jid: String,
  20. pub sender_jid: String,
  21. pub sender_name: Option<String>,
  22. pub text: String,
  23. pub timestamp: i64,
  24. pub from_me: bool,
  25. pub is_ai: bool,
  26. }
  27. /// A chat as shown in the chat list.
  28. #[derive(Debug, Clone, PartialEq)]
  29. pub struct ChatSummary {
  30. pub jid: String,
  31. pub name: String,
  32. pub last_timestamp: i64,
  33. pub autoreply: bool,
  34. }
  35. /// A named contact.
  36. #[derive(Debug, Clone, PartialEq)]
  37. pub struct Contact {
  38. pub jid: String,
  39. pub name: String,
  40. }
  41. /// Local SQLite message history.
  42. pub struct History {
  43. connection: Connection,
  44. }
  45. impl History {
  46. /// Purpose: open (or create) the history database file and ensure the schema exists.
  47. /// Inputs: `path` of the SQLite file; missing parent directories are created.
  48. /// Output: a ready-to-use `History`.
  49. /// Side effects: may create directories and the database file; creates missing tables.
  50. pub fn open(path: &Path) -> Result<History> {
  51. if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
  52. std::fs::create_dir_all(parent)
  53. .with_context(|| format!("create history directory {}", parent.display()))?;
  54. }
  55. let connection = Connection::open(path)
  56. .with_context(|| format!("open history database {}", path.display()))?;
  57. Self::from_connection(connection)
  58. }
  59. /// Purpose: create a throw-away history living only in memory (tests only).
  60. /// Inputs: none.
  61. /// Output: a ready-to-use `History`.
  62. /// Side effects: creates the schema in the in-memory database.
  63. #[cfg(test)]
  64. pub fn open_in_memory() -> Result<History> {
  65. let connection =
  66. Connection::open_in_memory().context("open in-memory history database")?;
  67. Self::from_connection(connection)
  68. }
  69. /// Purpose: wrap a connection and create the schema on it.
  70. /// Inputs: an open SQLite connection.
  71. /// Output: the `History` owning that connection.
  72. /// Side effects: executes the `CREATE TABLE IF NOT EXISTS` statements.
  73. fn from_connection(connection: Connection) -> Result<History> {
  74. connection
  75. .execute_batch(&schema_sql())
  76. .context("create history schema")?;
  77. Ok(History { connection })
  78. }
  79. /// Purpose: persist a message once, keeping the chat row up to date.
  80. /// Inputs: the message to store (deduplicated by `message_id`).
  81. /// Output: `true` if the message was newly inserted, `false` if it already existed.
  82. /// Side effects: inserts into messages; upserts chats (last_timestamp = max, name from
  83. /// the sender push name when the chat has none and the message is not from me), atomically.
  84. pub fn save_message(&self, message: &StoredMessage) -> Result<bool> {
  85. let context = || format!("save message {} in chat {}", message.message_id, message.chat_jid);
  86. let transaction = self.connection.unchecked_transaction().with_context(context)?;
  87. let inserted = insert_message(&transaction, message).with_context(context)?;
  88. if inserted {
  89. upsert_chat_for_message(&transaction, message).with_context(context)?;
  90. }
  91. transaction.commit().with_context(context)?;
  92. Ok(inserted)
  93. }
  94. /// Purpose: fetch the latest messages of a chat.
  95. /// Inputs: `chat_jid` and the maximum number of messages (`limit`).
  96. /// Output: up to `limit` most recent messages, in ascending timestamp order.
  97. /// Side effects: none (read only).
  98. pub fn recent_messages(&self, chat_jid: &str, limit: usize) -> Result<Vec<StoredMessage>> {
  99. let context = || format!("read last {limit} messages of chat {chat_jid}");
  100. let sql = format!(
  101. "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
  102. FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 \
  103. ORDER BY timestamp DESC, rowid DESC LIMIT ?2"
  104. );
  105. let limit = i64::try_from(limit).unwrap_or(i64::MAX);
  106. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  107. let mut messages = statement
  108. .query_map(params![chat_jid, limit], message_from_row)
  109. .with_context(context)?
  110. .collect::<rusqlite::Result<Vec<_>>>()
  111. .with_context(context)?;
  112. messages.reverse();
  113. Ok(messages)
  114. }
  115. /// Purpose: list all chats for the UI.
  116. /// Inputs: none.
  117. /// Output: chats ordered by `last_timestamp` descending; name = contact name, else chat
  118. /// name, else the user part of the jid.
  119. /// Side effects: none (read only).
  120. pub fn list_chats(&self) -> Result<Vec<ChatSummary>> {
  121. let context = || "list chats";
  122. let sql = format!(
  123. "SELECT c.jid, COALESCE(k.name, c.name), c.last_timestamp, c.autoreply \
  124. FROM {TABLE_CHATS} c LEFT JOIN {TABLE_CONTACTS} k ON k.jid = c.jid \
  125. ORDER BY c.last_timestamp DESC, c.jid"
  126. );
  127. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  128. let chats = statement
  129. .query_map([], chat_summary_from_row)
  130. .with_context(context)?
  131. .collect::<rusqlite::Result<Vec<_>>>()
  132. .with_context(context)?;
  133. Ok(chats)
  134. }
  135. /// Purpose: store or rename a contact.
  136. /// Inputs: the contact; a blank name is ignored.
  137. /// Output: `()`.
  138. /// Side effects: upserts the contacts row unless the name is blank.
  139. pub fn upsert_contact(&self, contact: &Contact) -> Result<()> {
  140. if contact.name.trim().is_empty() {
  141. return Ok(());
  142. }
  143. let sql = format!(
  144. "INSERT INTO {TABLE_CONTACTS} (jid, name) VALUES (?1, ?2) \
  145. ON CONFLICT(jid) DO UPDATE SET name = excluded.name"
  146. );
  147. self.connection
  148. .execute(&sql, params![contact.jid, contact.name])
  149. .with_context(|| format!("upsert contact {}", contact.jid))?;
  150. Ok(())
  151. }
  152. /// Purpose: list all known contacts.
  153. /// Inputs: none.
  154. /// Output: contacts ordered by name, case-insensitive.
  155. /// Side effects: none (read only).
  156. pub fn list_contacts(&self) -> Result<Vec<Contact>> {
  157. let context = || "list contacts";
  158. let sql = format!(
  159. "SELECT jid, name FROM {TABLE_CONTACTS} ORDER BY name COLLATE NOCASE, jid"
  160. );
  161. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  162. let contacts = statement
  163. .query_map([], |row| {
  164. Ok(Contact {
  165. jid: row.get(0)?,
  166. name: row.get(1)?,
  167. })
  168. })
  169. .with_context(context)?
  170. .collect::<rusqlite::Result<Vec<_>>>()
  171. .with_context(context)?;
  172. Ok(contacts)
  173. }
  174. /// Purpose: resolve the human-readable name of a jid.
  175. /// Inputs: `jid` (chat or contact).
  176. /// Output: contact name, else chat name, else the user part of the jid.
  177. /// Side effects: none (read only).
  178. pub fn display_name(&self, jid: &str) -> Result<String> {
  179. let sql = format!(
  180. "SELECT COALESCE((SELECT name FROM {TABLE_CONTACTS} WHERE jid = ?1), \
  181. (SELECT name FROM {TABLE_CHATS} WHERE jid = ?1))"
  182. );
  183. let stored_name: Option<String> = self
  184. .connection
  185. .query_row(&sql, params![jid], |row| row.get(0))
  186. .with_context(|| format!("resolve display name of {jid}"))?;
  187. Ok(stored_name.unwrap_or_else(|| jid_user_part(jid).to_string()))
  188. }
  189. /// Purpose: enable or disable the AI autoreply for a chat.
  190. /// Inputs: `chat_jid` and the new state.
  191. /// Output: `()`.
  192. /// Side effects: upserts the chats row (created with default values if missing).
  193. pub fn set_autoreply(&self, chat_jid: &str, enabled: bool) -> Result<()> {
  194. let sql = format!(
  195. "INSERT INTO {TABLE_CHATS} (jid, autoreply) VALUES (?1, ?2) \
  196. ON CONFLICT(jid) DO UPDATE SET autoreply = excluded.autoreply"
  197. );
  198. self.connection
  199. .execute(&sql, params![chat_jid, enabled])
  200. .with_context(|| format!("set autoreply={enabled} for chat {chat_jid}"))?;
  201. Ok(())
  202. }
  203. /// Purpose: tell whether the AI autoreply is enabled for a chat.
  204. /// Inputs: `chat_jid`.
  205. /// Output: the stored flag, or the default (`false`) if the chat has no row.
  206. /// Side effects: none (read only).
  207. pub fn is_autoreply(&self, chat_jid: &str) -> Result<bool> {
  208. let sql = format!("SELECT autoreply FROM {TABLE_CHATS} WHERE jid = ?1");
  209. let enabled: Option<bool> = self
  210. .connection
  211. .query_row(&sql, params![chat_jid], |row| row.get(0))
  212. .optional()
  213. .with_context(|| format!("read autoreply of chat {chat_jid}"))?;
  214. Ok(enabled.unwrap_or(DEFAULT_AUTOREPLY))
  215. }
  216. }
  217. /// Purpose: build the `CREATE TABLE/INDEX IF NOT EXISTS` script of the whole schema.
  218. /// Inputs: none.
  219. /// Output: the SQL script.
  220. /// Side effects: none.
  221. fn schema_sql() -> String {
  222. format!(
  223. "CREATE TABLE IF NOT EXISTS {TABLE_MESSAGES} (
  224. message_id TEXT PRIMARY KEY,
  225. chat_jid TEXT NOT NULL,
  226. sender_jid TEXT NOT NULL,
  227. sender_name TEXT,
  228. text TEXT NOT NULL,
  229. timestamp INTEGER NOT NULL,
  230. from_me INTEGER NOT NULL,
  231. is_ai INTEGER NOT NULL
  232. );
  233. CREATE INDEX IF NOT EXISTS {INDEX_MESSAGES_BY_CHAT}
  234. ON {TABLE_MESSAGES} (chat_jid, timestamp);
  235. CREATE TABLE IF NOT EXISTS {TABLE_CHATS} (
  236. jid TEXT PRIMARY KEY,
  237. name TEXT,
  238. last_timestamp INTEGER NOT NULL DEFAULT {DEFAULT_LAST_TIMESTAMP},
  239. autoreply INTEGER NOT NULL DEFAULT {default_autoreply}
  240. );
  241. CREATE TABLE IF NOT EXISTS {TABLE_CONTACTS} (
  242. jid TEXT PRIMARY KEY,
  243. name TEXT NOT NULL
  244. );",
  245. default_autoreply = i64::from(DEFAULT_AUTOREPLY),
  246. )
  247. }
  248. /// Purpose: insert a message unless its id is already stored.
  249. /// Inputs: a connection (or transaction) and the message.
  250. /// Output: `true` if a row was inserted, `false` if ignored as duplicate.
  251. /// Side effects: writes into messages.
  252. fn insert_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<bool> {
  253. let sql = format!(
  254. "INSERT OR IGNORE INTO {TABLE_MESSAGES} \
  255. (message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai) \
  256. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
  257. );
  258. let changed = connection.execute(
  259. &sql,
  260. params![
  261. message.message_id,
  262. message.chat_jid,
  263. message.sender_jid,
  264. message.sender_name,
  265. message.text,
  266. message.timestamp,
  267. message.from_me,
  268. message.is_ai,
  269. ],
  270. )?;
  271. Ok(changed > 0)
  272. }
  273. /// Purpose: upsert the chat row touched by a newly stored message.
  274. /// Inputs: a connection (or transaction) and the message.
  275. /// Output: `()`.
  276. /// Side effects: writes into chats; keeps the max `last_timestamp` and never overwrites an
  277. /// existing non-empty name.
  278. fn upsert_chat_for_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<()> {
  279. let sql = format!(
  280. "INSERT INTO {TABLE_CHATS} (jid, name, last_timestamp) VALUES (?1, ?2, ?3) \
  281. ON CONFLICT(jid) DO UPDATE SET \
  282. last_timestamp = MAX(last_timestamp, excluded.last_timestamp), \
  283. name = COALESCE(NULLIF(name, ''), excluded.name)"
  284. );
  285. connection.execute(
  286. &sql,
  287. params![message.chat_jid, chat_name_candidate(message), message.timestamp],
  288. )?;
  289. Ok(())
  290. }
  291. /// Purpose: pick the chat name a message may provide.
  292. /// Inputs: the message.
  293. /// Output: the trimmed sender push name for incoming messages, `None` otherwise or if blank.
  294. /// Side effects: none.
  295. fn chat_name_candidate(message: &StoredMessage) -> Option<&str> {
  296. if message.from_me {
  297. return None;
  298. }
  299. message
  300. .sender_name
  301. .as_deref()
  302. .map(str::trim)
  303. .filter(|name| !name.is_empty())
  304. }
  305. /// Purpose: extract the user part of a jid (text before `@`).
  306. /// Inputs: `jid`.
  307. /// Output: the user part, or the whole jid if it has no separator.
  308. /// Side effects: none.
  309. fn jid_user_part(jid: &str) -> &str {
  310. jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
  311. }
  312. /// Purpose: map a messages row (columns in `recent_messages` order) to a `StoredMessage`.
  313. /// Inputs: the SQLite row.
  314. /// Output: the message, or the column conversion error.
  315. /// Side effects: none.
  316. fn message_from_row(row: &Row<'_>) -> rusqlite::Result<StoredMessage> {
  317. Ok(StoredMessage {
  318. message_id: row.get(0)?,
  319. chat_jid: row.get(1)?,
  320. sender_jid: row.get(2)?,
  321. sender_name: row.get(3)?,
  322. text: row.get(4)?,
  323. timestamp: row.get(5)?,
  324. from_me: row.get(6)?,
  325. is_ai: row.get(7)?,
  326. })
  327. }
  328. /// Purpose: map a chats row (jid, resolved name, last_timestamp, autoreply) to a `ChatSummary`.
  329. /// Inputs: the SQLite row; a NULL name falls back to the user part of the jid.
  330. /// Output: the summary, or the column conversion error.
  331. /// Side effects: none.
  332. fn chat_summary_from_row(row: &Row<'_>) -> rusqlite::Result<ChatSummary> {
  333. let jid: String = row.get(0)?;
  334. let stored_name: Option<String> = row.get(1)?;
  335. let name = stored_name.unwrap_or_else(|| jid_user_part(&jid).to_string());
  336. Ok(ChatSummary {
  337. jid,
  338. name,
  339. last_timestamp: row.get(2)?,
  340. autoreply: row.get(3)?,
  341. })
  342. }
  343. #[cfg(test)]
  344. mod tests {
  345. use super::*;
  346. const CHAT_A: &str = "393331111111@s.whatsapp.net";
  347. const CHAT_B: &str = "393332222222@s.whatsapp.net";
  348. const GROUP: &str = "120363000000@g.us";
  349. /// Builds an incoming message with the given id, chat and timestamp.
  350. fn message(message_id: &str, chat_jid: &str, timestamp: i64) -> StoredMessage {
  351. StoredMessage {
  352. message_id: message_id.to_string(),
  353. chat_jid: chat_jid.to_string(),
  354. sender_jid: chat_jid.to_string(),
  355. sender_name: None,
  356. text: format!("text {message_id}"),
  357. timestamp,
  358. from_me: false,
  359. is_ai: false,
  360. }
  361. }
  362. /// Builds a contact from jid and name.
  363. fn contact(jid: &str, name: &str) -> Contact {
  364. Contact {
  365. jid: jid.to_string(),
  366. name: name.to_string(),
  367. }
  368. }
  369. #[test]
  370. fn duplicate_message_id_is_ignored() {
  371. let history = History::open_in_memory().unwrap();
  372. let original = message("m1", CHAT_A, 10);
  373. let mut duplicate = message("m1", CHAT_A, 20);
  374. duplicate.text = "changed".to_string();
  375. assert!(history.save_message(&original).unwrap());
  376. assert!(!history.save_message(&duplicate).unwrap());
  377. let stored = history.recent_messages(CHAT_A, 10).unwrap();
  378. assert_eq!(stored, vec![original]);
  379. }
  380. #[test]
  381. fn recent_messages_respects_limit_and_ascending_order() {
  382. let history = History::open_in_memory().unwrap();
  383. for (id, timestamp) in [("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)] {
  384. history.save_message(&message(id, CHAT_A, timestamp)).unwrap();
  385. }
  386. history.save_message(&message("other", CHAT_B, 50)).unwrap();
  387. let ids: Vec<String> = history
  388. .recent_messages(CHAT_A, 3)
  389. .unwrap()
  390. .into_iter()
  391. .map(|stored| stored.message_id)
  392. .collect();
  393. assert_eq!(ids, ["m2", "m3", "m4"]);
  394. assert!(history.recent_messages(CHAT_A, 0).unwrap().is_empty());
  395. }
  396. #[test]
  397. fn message_flags_and_sender_name_roundtrip() {
  398. let history = History::open_in_memory().unwrap();
  399. let mut sent = message("m1", CHAT_A, 10);
  400. sent.from_me = true;
  401. sent.is_ai = true;
  402. sent.sender_name = Some("Me".to_string());
  403. history.save_message(&sent).unwrap();
  404. assert_eq!(history.recent_messages(CHAT_A, 1).unwrap(), vec![sent]);
  405. }
  406. #[test]
  407. fn list_chats_orders_by_last_timestamp_desc_and_keeps_max() {
  408. let history = History::open_in_memory().unwrap();
  409. history.save_message(&message("a1", CHAT_A, 100)).unwrap();
  410. history.save_message(&message("b1", CHAT_B, 200)).unwrap();
  411. history.save_message(&message("a0", CHAT_A, 50)).unwrap();
  412. let chats = history.list_chats().unwrap();
  413. let summary: Vec<(&str, i64)> = chats
  414. .iter()
  415. .map(|chat| (chat.jid.as_str(), chat.last_timestamp))
  416. .collect();
  417. assert_eq!(summary, [(CHAT_B, 200), (CHAT_A, 100)]);
  418. }
  419. #[test]
  420. fn chat_name_falls_back_contact_then_chat_name_then_jid_user_part() {
  421. let history = History::open_in_memory().unwrap();
  422. history.save_message(&message("a1", CHAT_A, 30)).unwrap();
  423. let mut named = message("b1", CHAT_B, 20);
  424. named.sender_name = Some("Push Bob".to_string());
  425. history.save_message(&named).unwrap();
  426. history.save_message(&message("g1", GROUP, 10)).unwrap();
  427. let names = |history: &History| -> Vec<String> {
  428. history.list_chats().unwrap().into_iter().map(|chat| chat.name).collect()
  429. };
  430. assert_eq!(names(&history), ["393331111111", "Push Bob", "120363000000"]);
  431. history.upsert_contact(&contact(CHAT_B, "Roberto")).unwrap();
  432. assert_eq!(names(&history), ["393331111111", "Roberto", "120363000000"]);
  433. assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
  434. assert_eq!(history.display_name(CHAT_B).unwrap(), "Roberto");
  435. assert_eq!(history.display_name("unknown@s.whatsapp.net").unwrap(), "unknown");
  436. }
  437. #[test]
  438. fn chat_name_is_set_once_and_never_from_own_messages() {
  439. let history = History::open_in_memory().unwrap();
  440. let mut own = message("m1", CHAT_A, 10);
  441. own.from_me = true;
  442. own.sender_name = Some("Me".to_string());
  443. history.save_message(&own).unwrap();
  444. assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
  445. let mut first = message("m2", CHAT_A, 20);
  446. first.sender_name = Some("Alice".to_string());
  447. let mut second = message("m3", CHAT_A, 30);
  448. second.sender_name = Some("Alice Renamed".to_string());
  449. history.save_message(&first).unwrap();
  450. history.save_message(&second).unwrap();
  451. assert_eq!(history.display_name(CHAT_A).unwrap(), "Alice");
  452. }
  453. #[test]
  454. fn autoreply_defaults_to_false_and_toggles() {
  455. let history = History::open_in_memory().unwrap();
  456. assert!(!history.is_autoreply(CHAT_A).unwrap());
  457. history.set_autoreply(CHAT_A, true).unwrap();
  458. assert!(history.is_autoreply(CHAT_A).unwrap());
  459. assert!(!history.is_autoreply(CHAT_B).unwrap());
  460. history.set_autoreply(CHAT_A, false).unwrap();
  461. assert!(!history.is_autoreply(CHAT_A).unwrap());
  462. }
  463. #[test]
  464. fn autoreply_survives_incoming_messages_and_shows_in_chat_list() {
  465. let history = History::open_in_memory().unwrap();
  466. history.set_autoreply(CHAT_A, true).unwrap();
  467. history.save_message(&message("m1", CHAT_A, 10)).unwrap();
  468. let chats = history.list_chats().unwrap();
  469. assert_eq!(chats.len(), 1);
  470. assert!(chats[0].autoreply);
  471. assert_eq!(chats[0].last_timestamp, 10);
  472. }
  473. #[test]
  474. fn list_contacts_is_ordered_case_insensitive() {
  475. let history = History::open_in_memory().unwrap();
  476. history.upsert_contact(&contact("3@s.whatsapp.net", "bruno")).unwrap();
  477. history.upsert_contact(&contact("1@s.whatsapp.net", "Carla")).unwrap();
  478. history.upsert_contact(&contact("2@s.whatsapp.net", "Anna")).unwrap();
  479. let names: Vec<String> = history
  480. .list_contacts()
  481. .unwrap()
  482. .into_iter()
  483. .map(|stored| stored.name)
  484. .collect();
  485. assert_eq!(names, ["Anna", "bruno", "Carla"]);
  486. }
  487. #[test]
  488. fn upsert_contact_ignores_blank_names_and_renames_existing() {
  489. let history = History::open_in_memory().unwrap();
  490. history.upsert_contact(&contact(CHAT_A, "")).unwrap();
  491. history.upsert_contact(&contact(CHAT_A, " ")).unwrap();
  492. assert!(history.list_contacts().unwrap().is_empty());
  493. history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
  494. history.upsert_contact(&contact(CHAT_A, "")).unwrap();
  495. history.upsert_contact(&contact(CHAT_A, "Anna B")).unwrap();
  496. assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna B")]);
  497. }
  498. #[test]
  499. fn open_persists_data_on_disk_and_creates_parent_directory() {
  500. let directory = std::env::temp_dir().join(format!("wh-history-test-{}", std::process::id()));
  501. let path = directory.join("nested").join("history.db");
  502. {
  503. let history = History::open(&path).unwrap();
  504. history.save_message(&message("m1", CHAT_A, 10)).unwrap();
  505. }
  506. let reopened = History::open(&path).unwrap();
  507. assert_eq!(reopened.recent_messages(CHAT_A, 10).unwrap().len(), 1);
  508. drop(reopened);
  509. std::fs::remove_dir_all(&directory).unwrap();
  510. }
  511. }