diff --git a/notmuch/Cargo.toml b/notmuch/Cargo.toml index 22cd8e2..d4aa7df 100644 --- a/notmuch/Cargo.toml +++ b/notmuch/Cargo.toml @@ -6,4 +6,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +log = "0.4.14" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.68" +thiserror = "1.0.30" +[dev-dependencies] +pretty_assertions = "1" diff --git a/notmuch/src/lib.rs b/notmuch/src/lib.rs index 7626b8f..9f75fd0 100644 --- a/notmuch/src/lib.rs +++ b/notmuch/src/lib.rs @@ -208,12 +208,13 @@ use std::{ ffi::OsStr, - io::Result, + io, path::{Path, PathBuf}, process::Command, }; -use serde::Deserialize; +use log::info; +use serde::{Deserialize, Serialize}; /// # Number of seconds since the Epoch pub type UnixTime = isize; @@ -226,21 +227,21 @@ pub type MessageId = String; /// A top-level set of threads (do_show) /// Returned by notmuch show without a --part argument -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct ThreadSet(pub Vec); /// Top-level messages in a thread (show_messages) -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct Thread(pub Vec); /// A message and its replies (show_messages) -#[derive(Deserialize, Debug)] -pub struct ThreadNode { - pub message: Option, // null if not matched and not --entire-thread - pub children: Vec, // children of message -} +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct ThreadNode( + pub Option, // null if not matched and not --entire-thread + pub Vec, // children of message +); /// A message (format_part_sprinter) -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct Message { pub id: MessageId, pub r#match: bool, @@ -251,34 +252,53 @@ pub struct Message { pub tags: Vec, pub headers: Headers, + #[serde(skip_serializing_if = "Option::is_none")] pub body: Option>, // omitted if --body=false } /// The headers of a message or part (format_headers_sprinter with reply = FALSE) -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] +#[serde(rename_all = "PascalCase")] pub struct Headers { pub subject: String, pub from: String, + #[serde(skip_serializing_if = "Option::is_none")] pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub reply_to: Option, pub date: String, } -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(untagged)] pub enum IntOrString { Int(isize), String(String), } -#[derive(Deserialize, Debug)] +impl Default for IntOrString { + fn default() -> Self { + IntOrString::Int(0) + } +} + +impl From for IntOrString { + fn from(i: isize) -> Self { + IntOrString::Int(i) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct Rfc822 { pub headers: Headers, pub body: Vec, } -#[derive(Deserialize, Debug)] -#[serde(tag = "type")] +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(untagged)] pub enum Content { /// if content-type starts with "multipart/": Multipart(Vec), @@ -286,79 +306,110 @@ pub enum Content { Rfc822(Vec), /// otherwise (leaf parts): Leaf { + #[serde(skip_serializing_if = "Option::is_none")] filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] content_charset: Option, /// A leaf part's body content is optional, but may be included if /// it can be correctly encoded as a string. Consumers should use /// this in preference to fetching the part content separately. + #[serde(skip_serializing_if = "Option::is_none")] content: Option, // If a leaf part's body content is not included, the length of // the encoded content (in bytes) may be given instead. + #[serde(skip_serializing_if = "Option::is_none")] content_length: Option, // If a leaf part's body content is not included, its transfer encoding // may be given. Using this and the encoded content length, it is // possible for the consumer to estimate the decoded content length. + #[serde(skip_serializing_if = "Option::is_none")] content_transfer_encoding: Option, }, + // TODO(wathiede): flatten Leaf variant to replace this. + String(String), +} + +impl Default for Content { + fn default() -> Self { + Content::Leaf { + filename: None, + content_charset: None, + content: None, + content_length: None, + content_transfer_encoding: None, + } + } } /// A MIME part -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] pub struct Part { pub id: IntOrString, // part id (currently DFS part number) + #[serde(skip_serializing_if = "Option::is_none")] pub encstatus: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub sigstatus: Option, #[serde(rename = "content-type")] pub content_type: String, + #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "content-id")] pub content_id: Option, - //pub content: Content, + pub content: Content, } /// `encstatus = [{status: "good"|"bad"}]` pub type EncStatus = String; /// # Signature status (format_part_sigstatus_sprinter) -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SigStatus(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub enum Signature { /// (signature_status_to_string) Good { + #[serde(skip_serializing_if = "Option::is_none")] fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] created: Option, + #[serde(skip_serializing_if = "Option::is_none")] expires: Option, + #[serde(skip_serializing_if = "Option::is_none")] userid: Option, }, None { + #[serde(skip_serializing_if = "Option::is_none")] keyid: Option, }, Bad { + #[serde(skip_serializing_if = "Option::is_none")] keyid: Option, }, Unknown { + #[serde(skip_serializing_if = "Option::is_none")] keyid: Option, }, Error { + #[serde(skip_serializing_if = "Option::is_none")] keyid: Option, + #[serde(skip_serializing_if = "Option::is_none")] errors: Option, }, } -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SearchSummary(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SearchThreads(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SearchMessages(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SearchFiles(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct SearchTags(pub Vec); -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct ThreadSummary { pub thread: ThreadId, pub timestamp: UnixTime, @@ -383,33 +434,55 @@ pub struct ThreadSummary { // TODO(wathiede): notmuch reply schema -struct Notmuch { - config_path: PathBuf, +#[derive(thiserror::Error, Debug)] +pub enum NotmuchError { + #[error("notmuch execution error")] + Notmuch(#[from] io::Error), + #[error("json decoding error")] + SerdeJson(#[from] serde_json::Error), +} + +#[derive(Default)] +pub struct Notmuch { + config_path: Option, } impl Notmuch { pub fn with_config>(config_path: P) -> Notmuch { Notmuch { - config_path: config_path.as_ref().into(), + config_path: Some(config_path.as_ref().into()), } } - pub fn new(&self) -> Result> { + pub fn new(&self) -> Result, NotmuchError> { self.run_notmuch(["new"]) } - pub fn no_args(&self) -> Result> { + pub fn no_args(&self) -> Result, NotmuchError> { self.run_notmuch(std::iter::empty::<&str>()) } - fn run_notmuch(&self, args: I) -> Result> + pub fn search(&self, query: &str) -> Result { + let res = self.run_notmuch(["search", "--format=json", "--limit=20", query])?; + Ok(serde_json::from_slice(&res)?) + } + + pub fn show(&self, query: &str) -> Result { + let res = self.run_notmuch(["show", "--format=json", query])?; + Ok(serde_json::from_slice(&res)?) + } + + fn run_notmuch(&self, args: I) -> Result, NotmuchError> where I: IntoIterator, S: AsRef, { let mut cmd = Command::new("notmuch"); - let cmd = cmd.arg("--config").arg(&self.config_path); - let cmd = cmd.args(args); + if let Some(config_path) = &self.config_path { + cmd.arg("--config").arg(config_path); + } + cmd.args(args); + info!("{:?}", &cmd); dbg!(&cmd); let out = cmd.output()?; Ok(out.stdout) @@ -421,7 +494,7 @@ mod tests { use super::*; #[test] - fn new() -> Result<()> { + fn new() -> Result<(), NotmuchError> { let nm = Notmuch::with_config("testdata/notmuch.config"); nm.new()?; let output = nm.no_args()?; @@ -433,4 +506,86 @@ mod tests { ); Ok(()) } + #[test] + fn search() -> Result<(), NotmuchError> { + let nm = Notmuch::with_config("testdata/notmuch.config"); + nm.new()?; + let res = nm.search("goof")?; + assert_eq!(res.0.len(), 1); + Ok(()) + } + #[test] + fn show() -> Result<(), NotmuchError> { + let nm = Notmuch::with_config("testdata/notmuch.config"); + nm.new()?; + let res = nm.show("goof")?; + assert_eq!(res.0.len(), 1); + Ok(()) + } + + #[test] + fn thread_set_serde() { + let ts = ThreadSet(vec![Thread(vec![ThreadNode( + Some(Message { + id: "4khpM7BF.1187467196.1017920.wathiede.xinu@localhost".to_string(), + r#match: true, + excluded: false, + filename: vec!["/file/path/email.txt".to_string()], + timestamp: 1187467196, + date_relative: "2007-08-18".to_string(), + tags: vec!["inbox".to_string()], + body: Some(vec![Part { + id: 1.into(), + content_type: "multipart/mixed".to_string(), + content: Content::Multipart(vec![ + Part { + id: 2.into(), + content_type: "text/plain".to_string(), + content: Content::String("Spam detection software".to_string()), + ..Default::default() + }, + Part { + id: 3.into(), + content_type: "message/rfc822".to_string(), + content: Content::Rfc822(vec![Rfc822 { + headers: Headers { + subject: "Re: Registration goof".to_string(), + from: "\"Bill Thiede\" ".to_string(), + to: Some( + "jimpark@med.umich.edu, registration@a2ultimate.org" + .to_string(), + ), + date: "Sat, 18 Aug 2007 15:59:56 -0400".to_string(), + ..Default::default() + }, + body: vec![Part { + id: 4.into(), + content_type: "text/plain".to_string(), + content: Content::String("Hello".to_string()), + ..Default::default() + }], + }]), + ..Default::default() + }, + ]), + ..Default::default() + }]), + headers: Headers { + subject: "Re: Registration goof".to_string(), + from: "\"Bill Thiede\" ".to_string(), + to: Some("jimpark@med.umich.edu, registration@a2ultimate.org".to_string()), + date: "Sat, 18 Aug 2007 15:59:56 -0400".to_string(), + ..Default::default() + }, + }), + vec![], + )])]); + let s = serde_json::to_string_pretty(&ts).expect("failed to encode"); + println!("{}", s); + + let got = serde_json::from_str(include_str!("../testdata/thread_set.json")) + .expect("failed to decode"); + use pretty_assertions::assert_eq; + assert_eq!(ts, got); + } } diff --git a/notmuch/testdata/thread_set.json b/notmuch/testdata/thread_set.json new file mode 100644 index 0000000..9d889b9 --- /dev/null +++ b/notmuch/testdata/thread_set.json @@ -0,0 +1,63 @@ +[ + [ + [ + { + "id": "4khpM7BF.1187467196.1017920.wathiede.xinu@localhost", + "match": true, + "excluded": false, + "filename": [ + "/file/path/email.txt" + ], + "timestamp": 1187467196, + "date_relative": "2007-08-18", + "tags": [ + "inbox" + ], + "body": [ + { + "id": 1, + "content-type": "multipart/mixed", + "content": [ + { + "id": 2, + "content-type": "text/plain", + "content-disposition": "inline", + "content": "Spam detection software" + }, + { + "id": 3, + "content-type": "message/rfc822", + "content-disposition": "inline", + "content": [ + { + "headers": { + "Subject": "Re: Registration goof", + "From": "\"Bill Thiede\" ", + "To": "jimpark@med.umich.edu, registration@a2ultimate.org", + "Date": "Sat, 18 Aug 2007 15:59:56 -0400" + }, + "body": [ + { + "id": 4, + "content-type": "text/plain", + "content": "Hello" + } + ] + } + ] + } + ] + } + ], + "crypto": {}, + "headers": { + "Subject": "Re: Registration goof", + "From": "\"Bill Thiede\" ", + "To": "jimpark@med.umich.edu, registration@a2ultimate.org", + "Date": "Sat, 18 Aug 2007 15:59:56 -0400" + } + }, + [] + ] + ] +]