@ 260802.2

221221.1 · 55a686e0eb8396cd2add2b20d2953a7065586c81

bin/rust: add program to search through SMS backups

It searches through this crazy XML dump (gigabytes big) for text messages. It doesn't extract every bit of data (particularly media), but does pull out all of the plain text.

Normally I'd try to just do this sort of thing ad hoc with grep and awk, but holy hell XML is too annoying to deal with that way. And the data is just so big that it really helps to have an easy to use presentation style.

reading declarations

bin/rust/searchsms/.gitignore

new file mode 100644@@ -0,0 +1 @@+/target

bin/rust/searchsms/Cargo.lock

new file mode 100644@@ -0,0 +1,427 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 3++[[package]]+name = "aho-corasick"+version = "0.7.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"+dependencies = [+ "memchr",+]++[[package]]+name = "android_system_properties"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"+dependencies = [+ "libc",+]++[[package]]+name = "anyhow"+version = "1.0.68"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61"++[[package]]+name = "autocfg"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"++[[package]]+name = "bumpalo"+version = "3.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba"++[[package]]+name = "cc"+version = "1.0.78"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d"++[[package]]+name = "cfg-if"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"++[[package]]+name = "chrono"+version = "0.4.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"+dependencies = [+ "iana-time-zone",+ "num-integer",+ "num-traits",+ "winapi",+]++[[package]]+name = "codespan-reporting"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"+dependencies = [+ "termcolor",+ "unicode-width",+]++[[package]]+name = "core-foundation-sys"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"++[[package]]+name = "cxx"+version = "1.0.85"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5add3fc1717409d029b20c5b6903fc0c0b02fa6741d820054f4a2efa5e5816fd"+dependencies = [+ "cc",+ "cxxbridge-flags",+ "cxxbridge-macro",+ "link-cplusplus",+]++[[package]]+name = "cxx-build"+version = "1.0.85"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b4c87959ba14bc6fbc61df77c3fcfe180fc32b93538c4f1031dd802ccb5f2ff0"+dependencies = [+ "cc",+ "codespan-reporting",+ "once_cell",+ "proc-macro2",+ "quote",+ "scratch",+ "syn",+]++[[package]]+name = "cxxbridge-flags"+version = "1.0.85"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69a3e162fde4e594ed2b07d0f83c6c67b745e7f28ce58c6df5e6b6bef99dfb59"++[[package]]+name = "cxxbridge-macro"+version = "1.0.85"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3e7e2adeb6a0d4a282e581096b06e1791532b7d576dcde5ccd9382acf55db8e6"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "env_logger"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"+dependencies = [+ "humantime",+ "log",+ "termcolor",+]++[[package]]+name = "humantime"+version = "2.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"++[[package]]+name = "iana-time-zone"+version = "0.1.53"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765"+dependencies = [+ "android_system_properties",+ "core-foundation-sys",+ "iana-time-zone-haiku",+ "js-sys",+ "wasm-bindgen",+ "winapi",+]++[[package]]+name = "iana-time-zone-haiku"+version = "0.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca"+dependencies = [+ "cxx",+ "cxx-build",+]++[[package]]+name = "js-sys"+version = "0.3.60"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47"+dependencies = [+ "wasm-bindgen",+]++[[package]]+name = "lexopt"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8"++[[package]]+name = "libc"+version = "0.2.138"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8"++[[package]]+name = "link-cplusplus"+version = "1.0.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5"+dependencies = [+ "cc",+]++[[package]]+name = "log"+version = "0.4.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"+dependencies = [+ "cfg-if",+]++[[package]]+name = "memchr"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"++[[package]]+name = "num-integer"+version = "0.1.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"+dependencies = [+ "autocfg",+ "num-traits",+]++[[package]]+name = "num-traits"+version = "0.2.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"+dependencies = [+ "autocfg",+]++[[package]]+name = "once_cell"+version = "1.16.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"++[[package]]+name = "proc-macro2"+version = "1.0.49"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "regex"+version = "1.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.6.28"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"++[[package]]+name = "roxmltree"+version = "0.16.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fb6d47b59770b0ae88c7f270c68502832ec14d8c7ab5f7a584f204bb76dbfd8e"+dependencies = [+ "xmlparser",+]++[[package]]+name = "scratch"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2"++[[package]]+name = "searchsms"+version = "0.1.0"+dependencies = [+ "anyhow",+ "chrono",+ "env_logger",+ "lexopt",+ "log",+ "regex",+ "roxmltree",+ "textwrap",+]++[[package]]+name = "syn"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "termcolor"+version = "1.1.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"+dependencies = [+ "winapi-util",+]++[[package]]+name = "textwrap"+version = "0.16.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"++[[package]]+name = "unicode-ident"+version = "1.0.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc"++[[package]]+name = "unicode-width"+version = "0.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"++[[package]]+name = "wasm-bindgen"+version = "0.2.83"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268"+dependencies = [+ "cfg-if",+ "wasm-bindgen-macro",+]++[[package]]+name = "wasm-bindgen-backend"+version = "0.2.83"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142"+dependencies = [+ "bumpalo",+ "log",+ "once_cell",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.83"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.83"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-backend",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.83"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f"++[[package]]+name = "winapi"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"+dependencies = [+ "winapi-i686-pc-windows-gnu",+ "winapi-x86_64-pc-windows-gnu",+]++[[package]]+name = "winapi-i686-pc-windows-gnu"+version = "0.4.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"++[[package]]+name = "winapi-util"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"+dependencies = [+ "winapi",+]++[[package]]+name = "winapi-x86_64-pc-windows-gnu"+version = "0.4.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"++[[package]]+name = "xmlparser"+version = "0.13.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd"

bin/rust/searchsms/Cargo.toml

new file mode 100644@@ -0,0 +1,20 @@+[package]+name = "searchsms"+version = "0.1.0"+edition = "2021"++# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html++[dependencies]+anyhow = "1.0.68"+chrono = { version = "0.4.23", default-features = false, features = ["std", "clock"] }+env_logger = { version = "0.10.0", default-features = false, features = ["color", "humantime"] }+lexopt = "0.2.1"+log = "0.4.17"+regex = "1.7.0"+roxmltree = "0.16.0"+textwrap = { version = "0.16.0", default-features = false }++[[bin]]+name = "searchsms"+path = "main.rs"

bin/rust/searchsms/main.rs

new file mode 100644@@ -0,0 +1,437 @@+#![allow(warnings)]++use std::path::{Path, PathBuf};++use anyhow::Context;+use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, TimeZone};+use regex::Regex;+use roxmltree::{Document, Node};++fn main() -> anyhow::Result<()> {+    env_logger::init();++    let config = Config::parse(std::env::args_os())?;+    let mut raws: Vec<(&Path, String)> = vec![];+    for path in config.paths.iter() {+        log::trace!("reading {}", path.display());+        let raw = std::fs::read_to_string(path)+            .with_context(|| format!("{}", path.display()))?;+        raws.push((path, raw));+    }+    let mut docs = vec![];+    for (path, raw) in raws.iter() {+        log::trace!("parsing {}", path.display());+        let doc = Document::parse(raw)+            .with_context(|| format!("{}", path.display()))?;+        docs.push(Doc { path: path.to_path_buf(), doc });+    }+    let mut msgs = vec![];+    for doc in docs.iter() {+        log::trace!("searching {}", doc.path.display());+        let smses = doc.find_smses()?;+        for c in smses.children() {+            let Some(msg) = doc.extract(c)? else { continue };+            if let Some(ref re) = config.body_regex {+                if !re.is_match(&msg.body) {+                    continue;+                }+            }+            if let Some(ref re) = config.contact_regex {+                if !msg.contacts.iter().any(|c| re.is_match(c)) {+                    continue;+                }+            }+            msgs.push(msg);+        }+    }+    if config.reverse {+        msgs.sort_by(|m1, m2| m1.date.cmp(&m2.date).reverse());+    } else {+        msgs.sort_by(|m1, m2| m1.date.cmp(&m2.date));+    }+    output_human(&msgs, &mut std::io::stdout())?;+    Ok(())+}++#[derive(Debug)]+struct Message {+    /// Whether the message was sent by me or received by me.+    kind: MessageKind,+    /// The literal timestamp string associated with this message. This is+    /// useful as an ID of sorts for error reporting.+    timestamp: String,+    /// All of the contact names found in the message. This is the result of+    /// splitting the 'contact_name' string by comma.+    contacts: Vec<String>,+    /// All of the addresses found in the message.+    addresses: Vec<String>,+    /// The actual body of the message. For mms messages, this corresponds+    /// to the part that is 'text/plain'.+    body: String,+    /// A date associated with the message. It corresponds to the 'date'+    /// attribute and is in units of milliseconds. It is *probably* the+    /// "received datetime." It always appears to be in UTC.+    date: DateTime<Local>,+    /// A readable date string devoid of timezone info. It's probably just+    /// "naive local time."+    date_readable: Option<NaiveDateTime>,+    /// From what I can tell, this is equivalent to 'date', but with+    /// milliseconds always set to `0`. (Its units is still milliseconds for+    /// 'sms' but apparently seconds for 'mms'. It appears to always be in+    /// UTC.+    date_sent: Option<DateTime<Local>>,+}++#[derive(Clone, Copy, Debug)]+enum MessageKind {+    Sent,+    Recv,+}++#[derive(Debug)]+struct Doc<'i> {+    path: PathBuf,+    doc: Document<'i>,+}++impl<'i> Doc<'i> {+    fn find_smses<'a>(&'a self) -> anyhow::Result<Node<'a, 'i>> {+        for c in self.doc.root().children() {+            if c.tag_name().name() == "smses" {+                return Ok(c);+            }+        }+        anyhow::bail!(+            "{}: could not find first child of root",+            self.path.display()+        )+    }++    fn extract<'a>(+        &'a self,+        node: Node<'a, 'i>,+    ) -> anyhow::Result<Option<Message>> {+        if !node.is_element() {+            return Ok(None);+        }+        let msg = match node.tag_name().name() {+            // All SMS are just plain text, so we always get a message.+            "sms" => {+                let Some(timestamp) = node.attribute("date") else {+                    anyhow::bail!("failed to find timestamp for <sms> node")+                };+                Some(+                    self.extract_from_sms(node, timestamp)+                        .with_context(|| format!("{}", self.path.display()))+                        .with_context(|| {+                            format!("timestamp of <sms> node: {}", timestamp)+                        })?,+                )+            }+            // ... but an MMS might just be an image, so we might not get an+            // actual plain text message.+            "mms" => {+                let Some(timestamp) = node.attribute("date") else {+                    anyhow::bail!("failed to find timestamp for <mms> node")+                };+                self.extract_from_mms(node, timestamp)+                    .with_context(|| format!("{}", self.path.display()))+                    .with_context(|| {+                        format!("timestamp of <mms> node: {}", timestamp)+                    })?+            }+            unknown => anyhow::bail!(+                "{}: unknown child of 'smses': {}",+                self.path.display(),+                unknown+            ),+        };+        Ok(msg)+    }++    fn extract_from_sms<'a>(+        &'a self,+        node: Node<'a, 'i>,+        timestamp: &str,+    ) -> anyhow::Result<Message> {+        let mut kind = None;+        let mut contacts = vec![];+        let mut addresses = vec![];+        let mut body = None;+        let mut date = None;+        let mut date_readable = None;+        let mut date_sent = None;+        for a in node.attributes() {+            if a.name() == "type" {+                match a.value() {+                    "1" => kind = Some(MessageKind::Recv),+                    "2" => kind = Some(MessageKind::Sent),+                    unknown => {+                        anyhow::bail!("unknown SMS type '{:?}'", unknown)+                    }+                }+            } else if a.name() == "contact_name" {+                // Since this is just SMS, the contact name should always be+                // singular. I think...+                contacts.push(a.value().to_string());+            } else if a.name() == "address" {+                // Same as for contacts. For SMS, there should only be one.+                addresses.push(a.value().to_string());+            } else if a.name() == "body" {+                body = Some(a.value().to_string());+            } else if a.name() == "date" {+                date = Some(+                    parse_utc_timestamp_millis(a.value())+                        .context("'date' attribute")?,+                );+            } else if a.name() == "readable_date" {+                date_readable = Some(+                    parse_readable_date(a.value())+                        .context("'readable_date attribute")?,+                );+            } else if a.name() == "date_sent" && a.value() != "0" {+                date_sent = Some(+                    parse_utc_timestamp_millis(a.value())+                        .context("'date_sent' attribute")?,+                );+            }+        }+        let Some(kind) = kind else {+            anyhow::bail!("failed to find SMS message kind")+        };+        if contacts.is_empty() {+            anyhow::bail!("failed to find SMS message contacts")+        }+        let Some(body) = body else {+            anyhow::bail!("failed to find SMS message body")+        };+        let Some(date) = date else {+            anyhow::bail!("failed to find date for message")+        };+        Ok(Message {+            kind,+            timestamp: timestamp.to_string(),+            contacts,+            addresses,+            body,+            date,+            date_readable,+            date_sent,+        })+    }++    fn extract_from_mms<'a>(+        &'a self,+        node: Node<'a, 'i>,+        timestamp: &str,+    ) -> anyhow::Result<Option<Message>> {+        let Some(body) = self.extract_body_from_mms(node)? else {+            return Ok(None);+        };+        let mut kind = None;+        let mut contacts = vec![];+        let mut addresses = vec![];+        let mut date = None;+        let mut date_readable = None;+        let mut date_sent = None;+        for a in node.attributes() {+            if a.name() == "m_type" {+                match a.value() {+                    "132" => kind = Some(MessageKind::Recv),+                    "128" => kind = Some(MessageKind::Sent),+                    unknown => {+                        anyhow::bail!("unknown SMS type '{:?}'", unknown)+                    }+                }+            } else if a.name() == "contact_name" {+                contacts.extend(+                    a.value().split(",").map(|s| s.trim().to_string()),+                );+            } else if a.name() == "address" {+                addresses.extend(+                    a.value().split("~").map(|s| s.trim().to_string()),+                );+            } else if a.name() == "date" {+                date = Some(+                    parse_utc_timestamp_millis(a.value())+                        .context("'date' attribute")?,+                );+            } else if a.name() == "readable_date" {+                date_readable = Some(+                    parse_readable_date(a.value())+                        .context("'readable_date attribute")?,+                );+            } else if a.name() == "date_sent" && a.value() != "0" {+                date_sent = Some(+                    parse_utc_timestamp_seconds(a.value())+                        .context("'date_sent' attribute")?,+                );+            }+        }+        let Some(kind) = kind else {+            anyhow::bail!("failed to find SMS message kind")+        };+        if contacts.is_empty() {+            anyhow::bail!("failed to find SMS message contacts")+        }+        let Some(date) = date else {+            anyhow::bail!("failed to find date for message")+        };+        Ok(Some(Message {+            kind,+            timestamp: timestamp.to_string(),+            contacts,+            addresses,+            body,+            date,+            date_readable,+            date_sent,+        }))+    }++    fn extract_body_from_mms<'a>(+        &'a self,+        node: Node<'a, 'i>,+    ) -> anyhow::Result<Option<String>> {+        let mut body: Option<String> = None;+        for c in node.descendants() {+            if c.tag_name().name() != "part" {+                continue;+            }+            let Some(content_type) = c.attribute("ct") else { continue };+            if content_type != "text/plain" {+                continue;+            }+            let Some(text) = c.attribute("text") else {+                anyhow::bail!("'text/plain' part node has no 'text' attr")+            };+            if let Some(ref mut body) = body {+                body.push_str(" ... ");+                body.push_str(text);+            } else {+                body = Some(text.to_string());+            }+        }+        Ok(body)+    }+}++#[derive(Clone, Debug, Default)]+struct Config {+    body_regex: Option<Regex>,+    contact_regex: Option<Regex>,+    paths: Vec<PathBuf>,+    reverse: bool,+}++impl Config {+    /// Parse the given OS string args into a `window-grep`+    /// configuration.+    fn parse<I>(args: I) -> anyhow::Result<Config>+    where+        I: IntoIterator<Item = std::ffi::OsString> + 'static,+    {+        use lexopt::{Arg::*, ValueExt};++        const USAGE: &str = "+Usage: searchsms [options] <body-regex> <path> ...++Options:+  -c, --contact <REGEX>+    Only show text messages with a contact name matching the given regex.++  -r, --reverse+    Sort in reverse chronological order.+";++        let mut config = Config::default();+        let mut parser = lexopt::Parser::from_iter(args);+        while let Some(arg) = parser.next()? {+            match arg {+                Short('h') | Long("help") => {+                    anyhow::bail!(USAGE);+                }+                Short('c') | Long("contact") => {+                    let pat = parser.value()?.parse::<String>()?;+                    config.contact_regex = Some(Regex::new(&pat)?);+                }+                Short('r') | Long("reverse") => {+                    config.reverse = true;+                }+                Value(v) => {+                    if config.body_regex.is_none() {+                        let pat = v.parse::<String>()?;+                        config.body_regex = Some(Regex::new(&pat)?);+                    } else {+                        config.paths.push(PathBuf::from(v));+                    }+                }+                _ => return Err(arg.unexpected().into()),+            }+        }+        anyhow::ensure!(+            config.body_regex.is_some(),+            "must provide regex to search body of SMS",+        );+        anyhow::ensure!(+            !config.paths.is_empty(),+            "must provide at least one path"+        );+        Ok(config)+    }+}++fn output_human(+    msgs: &[Message],+    mut wtr: impl std::io::Write,+) -> anyhow::Result<()> {+    for (i, msg) in msgs.iter().enumerate() {+        if i > 0 {+            writeln!(wtr, "{}", "-".repeat(79))?;+        }+        match msg.kind {+            MessageKind::Sent => write!(wtr, "   TO: ")?,+            MessageKind::Recv => write!(wtr, " FROM: ")?,+        }+        writeln!(wtr, "{}", msg.contacts.join(", "))?;+        writeln!(wtr, "PHONE: {}", msg.addresses.join(", "))?;+        writeln!(wtr, " DATE: {}", msg.date.format("%Y-%m-%d %H:%M:%S"))?;+        writeln!(wtr, "")?;+        writeln!(wtr, "{}", textwrap::wrap(&msg.body, 79).join("\n"))?;+    }+    Ok(())+}++fn parse_utc_timestamp_seconds(+    value: &str,+) -> anyhow::Result<DateTime<Local>> {+    let seconds = value+        .parse::<i64>()+        .context("failed to parse timestamp as seconds")?;+    let Some(millis) = seconds.checked_mul(1000) else {+        anyhow::bail!("seconds overflowed when converting to milliseconds")+    };+    utc_datetime_from_timestamp_millis(millis)+}++fn parse_utc_timestamp_millis(value: &str) -> anyhow::Result<DateTime<Local>> {+    let millis = value+        .parse::<i64>()+        .context("failed to parse timestamp as milliseconds")?;+    utc_datetime_from_timestamp_millis(millis)+}++fn utc_datetime_from_timestamp_millis(+    millis: i64,+) -> anyhow::Result<DateTime<Local>> {+    let Some(ndt) = NaiveDateTime::from_timestamp_millis(millis) else {+        anyhow::bail!("invalid timestamp")+    };+    Ok(Local.from_utc_datetime(&ndt))+}++fn parse_readable_date(value: &str) -> anyhow::Result<NaiveDateTime> {+    // Example: Apr 1, 2022 20:46:15+    NaiveDateTime::parse_from_str(value, "%h %-d, %Y %H:%M:%S")+        .context("failed to parse readable date")+}

bin/rust/searchsms/rustfmt.toml

new file mode 100644@@ -0,0 +1,2 @@+max_width = 79+use_small_heuristics = "max"