//! Below is the file `devel/schemata` from the notmuch source tree.
//!
//! This file describes the schemata used for notmuch's structured output
//! format (currently JSON and S-Expressions).
//!
//! []'s indicate lists.  List items can be marked with a '?', meaning
//! they are optional; or a '*', meaning there can be zero or more of that
//! item.  {}'s indicate an object that maps from field identifiers to
//! values.  An object field marked '?' is optional.  |'s indicate
//! alternates (e.g., int|string means something can be an int or a
//! string).
//!
//! For S-Expression output, lists are printed delimited by () instead of
//! []. Objects are printed as p-lists, i.e. lists where the keys and values
//! are interleaved. Keys are printed as keywords (symbols preceded by a
//! colon), e.g. (:id "123" :time 54321 :from "foobar"). Null is printed as
//! nil, true as t and false as nil.
//!
//! This is version 2 of the structured output format.
//!
//! Version history
//! ---------------
//!
//! v1
//! - First versioned schema release.
//! - Added part.content-length and part.content-transfer-encoding fields.
//!
//! v2
//! - Added the thread_summary.query field.
//!
//! Common non-terminals
//! --------------------
//!
//! # Number of seconds since the Epoch
//! `unix_time = int`
//!
//! # Thread ID, sans "thread:"
//! `threadid = string`
//!
//! # Message ID, sans "id:"
//! `messageid = string`
//!
//! notmuch show schema
//! -------------------
//!
//! # A top-level set of threads (do_show)
//! # Returned by notmuch show without a --part argument
//! `thread_set = [thread*]`
//!
//! # Top-level messages in a thread (show_messages)
//! `thread = [thread_node*]`
//!
//! # A message and its replies (show_messages)
//! ```text
//! thread_node = [
//!     message|null,             # null if not matched and not --entire-thread
//!     [thread_node*]            # children of message
//! ]
//! ```
//!
//! # A message (format_part_sprinter)
//! ```text
//! message = {
//!     # (format_message_sprinter)
//!     id:             messageid,
//!     match:          bool,
//!     filename:           string,
//!     timestamp:      unix_time, # date header as unix time
//!     date_relative:  string,   # user-friendly timestamp
//!     tags:           [string*],
//!
//!     headers:        headers,
//!     body?:          [part]    # omitted if --body=false
//! }
//! ```
//!
//! # A MIME part (format_part_sprinter)
//! ```text
//! part = {
//!     id:             int|string, # part id (currently DFS part number)
//!
//!     encstatus?:     encstatus,
//!     sigstatus?:     sigstatus,
//!
//!     content-type:   string,
//!     content-id?:    string,
//!     # if content-type starts with "multipart/":
//!     content:        [part*],
//!     # if content-type is "message/rfc822":
//!     content:        [{headers: headers, body: [part]}],
//!     # otherwise (leaf parts):
//!     filename?:      string,
//!     content-charset?: string,
//!     # 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.
//!     content?:       string,
//!     # If a leaf part's body content is not included, the length of
//!     # the encoded content (in bytes) may be given instead.
//!     content-length?: int,
//!     # 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.
//!     content-transfer-encoding?: string
//! }
//! ```
//!
//! # The headers of a message or part (format_headers_sprinter with reply = FALSE)
//! ```text
//! headers = {
//!     Subject:        string,
//!     From:           string,
//!     To?:            string,
//!     Cc?:            string,
//!     Bcc?:           string,
//!     Reply-To?:      string,
//!     Date:           string
//! }
//! ```
//!
//! # Encryption status (format_part_sprinter)
//! `encstatus = [{status: "good"|"bad"}]`
//!
//! # Signature status (format_part_sigstatus_sprinter)
//! `sigstatus = [signature*]`
//!
//! ```text
//! signature = {
//!     # (signature_status_to_string)
//!     status:         "none"|"good"|"bad"|"error"|"unknown",
//!     # if status is "good":
//!     fingerprint?:   string,
//!     created?:       unix_time,
//!     expires?:       unix_time,
//!     userid?:        string
//!     # if status is not "good":
//!     keyid?:         string
//!     # if the signature has errors:
//!     errors?:        int
//! }
//! ```
//!
//! notmuch search schema
//! ---------------------
//!
//! # --output=summary
//! `search_summary = [thread_summary*]`
//!
//! # --output=threads
//! `search_threads = [threadid*]`
//!
//! # --output=messages
//! `search_messages = [messageid*]`
//!
//! # --output=files
//! `search_files = [string*]`
//!
//! # --output=tags
//! `search_tags = [string*]`
//!
//! ```text
//! thread_summary = {
//!     thread:         threadid,
//!     timestamp:      unix_time,
//!     date_relative:  string,   # user-friendly timestamp
//!     matched:        int,      # number of matched messages
//!     total:          int,      # total messages in thread
//!     authors:        string,   # comma-separated names with | between
//!                               # matched and unmatched
//!     subject:        string,
//!     tags:           [string*],
//!
//!     # Two stable query strings identifying exactly the matched and
//!     # unmatched messages currently in this thread.  The messages
//!     # matched by these queries will not change even if more messages
//!     # arrive in the thread.  If there are no matched or unmatched
//!     # messages, the corresponding query will be null (there is no
//!     # query that matches nothing).  (Added in schema version 2.)
//!     query:          [string|null, string|null],
//! }
//! ```
//!
//! notmuch reply schema
//! --------------------
//!
//! ```text
//! reply = {
//!     # The headers of the constructed reply
//!     reply-headers: reply_headers,
//!
//!     # As in the show format (format_part_sprinter)
//!     original: message
//! }
//! ```
//!
//! # Reply headers (format_headers_sprinter with reply = TRUE)
//! ```text
//! reply_headers = {
//!     Subject:        string,
//!     From:           string,
//!     To?:            string,
//!     Cc?:            string,
//!     Bcc?:           string,
//!     In-reply-to:    string,
//!     References:     string
//! }
//! ```

use std::{
    collections::HashMap,
    ffi::OsStr,
    io::{self},
    path::{Path, PathBuf},
    process::Command,
};

use serde::{Deserialize, Serialize};
use tracing::{error, info, instrument, warn};

/// # Number of seconds since the Epoch
pub type UnixTime = isize;

/// # Thread ID, sans "thread:"
pub type ThreadId = String;

/// # Message ID, sans "id:"
pub type MessageId = String;

/// A top-level set of threads (do_show)
/// Returned by notmuch show without a --part argument
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSet(pub Vec<Thread>);

/// Top-level messages in a thread (show_messages)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Thread(pub Vec<ThreadNode>);

/// A message and its replies (show_messages)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadNode(
    pub Option<Message>, // null if not matched and not --entire-thread
    pub Vec<ThreadNode>, // children of message
);
///  A message (format_part_sprinter)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Message {
    pub id: MessageId,
    pub r#match: bool,
    pub excluded: bool,
    pub filename: Vec<String>,
    pub timestamp: UnixTime,   // date header as unix time
    pub date_relative: String, // user-friendly timestamp
    pub tags: Vec<String>,

    pub headers: Headers,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub body: Option<Vec<Part>>, // omitted if --body=false
}

/// The headers of a message or part (format_headers_sprinter with reply = FALSE)
#[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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cc: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bcc: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(alias = "Delivered-To")]
    pub delivered_to: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(alias = "X-Original-To")]
    pub x_original_to: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<String>,
    pub date: String,
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum IntOrString {
    Int(isize),
    String(String),
}

impl Default for IntOrString {
    fn default() -> Self {
        IntOrString::Int(0)
    }
}

impl From<isize> for IntOrString {
    fn from(i: isize) -> Self {
        IntOrString::Int(i)
    }
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Rfc822 {
    pub headers: Headers,
    pub body: Vec<Part>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum Content {
    /// if content-type starts with "multipart/":
    Multipart(Vec<Part>),
    /// if content-type is "message/rfc822":
    Rfc822(Vec<Rfc822>),
    /// otherwise (leaf parts):
    Leaf {
        #[serde(skip_serializing_if = "Option::is_none")]
        filename: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        content_charset: Option<String>,
        /// 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<String>,
        // 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<isize>,
        // 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<String>,
    },
    // 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(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<EncStatus>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sigstatus: Option<SigStatus>,

    #[serde(rename = "content-type")]
    pub content_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "content-disposition")]
    pub content_disposition: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "content-transfer-encoding")]
    pub content_transfer_encoding: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "content-length")]
    pub content_length: Option<isize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "content-id")]
    pub content_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<Content>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,
}

/// `encstatus = [{status: "good"|"bad"}]`
pub type EncStatus = String;

/// # Signature status (format_part_sigstatus_sprinter)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SigStatus(pub Vec<Signature>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Signature {
    /// (signature_status_to_string)
    Good {
        #[serde(skip_serializing_if = "Option::is_none")]
        fingerprint: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        created: Option<UnixTime>,
        #[serde(skip_serializing_if = "Option::is_none")]
        expires: Option<UnixTime>,
        #[serde(skip_serializing_if = "Option::is_none")]
        userid: Option<String>,
    },
    None {
        #[serde(skip_serializing_if = "Option::is_none")]
        keyid: Option<String>,
    },
    Bad {
        #[serde(skip_serializing_if = "Option::is_none")]
        keyid: Option<String>,
    },
    Unknown {
        #[serde(skip_serializing_if = "Option::is_none")]
        keyid: Option<String>,
    },
    Error {
        #[serde(skip_serializing_if = "Option::is_none")]
        keyid: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        errors: Option<isize>,
    },
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchSummary(pub Vec<ThreadSummary>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchThreads(pub Vec<ThreadId>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchMessages(pub Vec<MessageId>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchFiles(pub Vec<String>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchTags(pub Vec<String>);

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSummary {
    pub thread: ThreadId,
    pub timestamp: UnixTime,
    /// user-friendly timestamp
    pub date_relative: String,
    /// number of matched messages
    pub matched: isize,
    /// total messages in thread
    pub total: isize,
    /// comma-separated names with | between matched and unmatched
    pub authors: String,
    pub subject: String,
    pub tags: Vec<String>,

    /// Two stable query strings identifying exactly the matched and unmatched messages currently
    /// in this thread.  The messages matched by these queries will not change even if more
    /// messages arrive in the thread.  If there are no matched or unmatched messages, the
    /// corresponding query will be null (there is no query that matches nothing).  (Added in
    /// schema version 2.)
    pub query: (Option<String>, Option<String>),
}

// TODO(wathiede): notmuch reply schema

#[derive(thiserror::Error, Debug)]
pub enum NotmuchError {
    #[error("notmuch execution error")]
    Notmuch(#[from] io::Error),
    #[error("json decoding error")]
    SerdeJson(#[from] serde_json::Error),
    #[error("failed to parse bytes as str")]
    Utf8Error(#[from] std::str::Utf8Error),
    #[error("failed to parse bytes as String")]
    StringUtf8Error(#[from] std::string::FromUtf8Error),
    #[error("failed to parse str as int")]
    ParseIntError(#[from] std::num::ParseIntError),
    #[error("failed to parse mail: {0}")]
    MailParseError(#[from] mailparse::MailParseError),
}

#[derive(Clone, Default)]
pub struct Notmuch {
    config_path: Option<PathBuf>,
}

// TODO: rewrite to use tokio::process::Command and make everything async to see if that helps with
// concurrency being more parallel.
impl Notmuch {
    pub fn with_config<P: AsRef<Path>>(config_path: P) -> Notmuch {
        Notmuch {
            config_path: Some(config_path.as_ref().into()),
        }
    }

    #[instrument(skip_all)]
    pub fn new(&self) -> Result<Vec<u8>, NotmuchError> {
        self.run_notmuch(["new"])
    }

    pub fn no_args(&self) -> Result<Vec<u8>, NotmuchError> {
        self.run_notmuch(std::iter::empty::<&str>())
    }

    #[instrument(skip_all, fields(query=query))]
    pub fn tags_for_query(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
        let res = self.run_notmuch(["search", "--format=json", "--output=tags", query])?;
        Ok(serde_json::from_slice(&res)?)
    }

    pub fn tags(&self) -> Result<Vec<String>, NotmuchError> {
        self.tags_for_query("*")
    }

    pub fn tag_add(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
        self.tags_add(tag, &[search_term])
    }

    #[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
    pub fn tags_add(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
        let tag = format!("+{tag}");
        let mut args = vec!["tag", &tag];
        args.extend(search_term);
        self.run_notmuch(&args)?;
        Ok(())
    }

    pub fn tag_remove(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
        self.tags_remove(tag, &[search_term])
    }
    #[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
    pub fn tags_remove(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
        let tag = format!("-{tag}");
        let mut args = vec!["tag", &tag];
        args.extend(search_term);
        self.run_notmuch(&args)?;
        Ok(())
    }

    #[instrument(skip_all, fields(query=query,offset=offset,limit=limit))]
    pub fn search(
        &self,
        query: &str,
        offset: usize,
        limit: usize,
    ) -> Result<SearchSummary, NotmuchError> {
        let query = if query.is_empty() { "*" } else { query };

        let res = self
            .run_notmuch([
                "search",
                "--format=json",
                &format!("--offset={offset}"),
                &format!("--limit={limit}"),
                query,
            ])
            .inspect_err(|err| error!("failed to notmuch search for query '{query}': {err}"))?;
        Ok(serde_json::from_slice(&res).unwrap_or_else(|err| {
            error!("failed to decode search result for query '{query}': {err}");
            SearchSummary(Vec::new())
        }))
    }

    #[instrument(skip_all, fields(query=query))]
    pub fn count(&self, query: &str) -> Result<usize, NotmuchError> {
        // NOTE: --output=threads is technically more correct, but really slow
        // TODO: find a fast thread count path
        // let res = self.run_notmuch(["count", "--output=threads", query])?;
        let res = self.run_notmuch(["count", query])?;
        // Strip '\n' from res.
        let s = std::str::from_utf8(&res)?.trim();
        Ok(s.parse()
            .inspect_err(|err| error!("failed to parse count for query '{query}': {err}"))
            .unwrap_or(0))
    }

    #[instrument(skip_all, fields(query=query))]
    pub fn show(&self, query: &str) -> Result<ThreadSet, NotmuchError> {
        let slice = self.run_notmuch([
            "show",
            "--include-html=true",
            "--entire-thread=false",
            "--format=json",
            query,
        ])?;
        // Notmuch returns JSON with invalid unicode.  So we lossy convert it to a string here and
        // use that for parsing in rust.
        let s = String::from_utf8_lossy(&slice);
        let mut deserializer = serde_json::Deserializer::from_str(&s);
        deserializer.disable_recursion_limit();
        let val = serde::de::Deserialize::deserialize(&mut deserializer)?;
        deserializer.end()?;
        Ok(val)
    }

    #[instrument(skip_all, fields(query=query,part=part))]
    pub fn show_part(&self, query: &str, part: usize) -> Result<Part, NotmuchError> {
        let slice = self.run_notmuch([
            "show",
            "--include-html=true",
            "--entire-thread=true",
            "--format=json",
            &format!("--part={}", part),
            query,
        ])?;
        // Notmuch returns JSON with invalid unicode.  So we lossy convert it to a string here and
        // use that for parsing in rust.
        let s = String::from_utf8_lossy(&slice);
        let mut deserializer = serde_json::Deserializer::from_str(&s);
        deserializer.disable_recursion_limit();
        let val = serde::de::Deserialize::deserialize(&mut deserializer)?;
        deserializer.end()?;
        Ok(val)
    }

    #[instrument(skip_all, fields(id=id))]
    pub fn show_original(&self, id: &MessageId) -> Result<Vec<u8>, NotmuchError> {
        self.show_original_part(id, 0)
    }

    #[instrument(skip_all, fields(id=id,part=part))]
    pub fn show_original_part(&self, id: &MessageId, part: usize) -> Result<Vec<u8>, NotmuchError> {
        let id = if id.starts_with("id:") {
            id
        } else {
            &format!("id:{id}")
        };
        let res = self.run_notmuch(["show", "--part", &part.to_string(), id])?;
        Ok(res)
    }

    #[instrument(skip_all, fields(query=query))]
    pub fn message_ids(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
        let res = self.run_notmuch(["search", "--output=messages", "--format=json", query])?;
        Ok(serde_json::from_slice(&res)?)
    }

    #[instrument(skip_all, fields(query=query))]
    pub fn files(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
        let res = self.run_notmuch(["search", "--output=files", "--format=json", query])?;
        Ok(serde_json::from_slice(&res)?)
    }

    #[instrument(skip_all)]
    pub fn unread_recipients(&self) -> Result<HashMap<String, usize>, NotmuchError> {
        let slice = self.run_notmuch([
            "show",
            "--include-html=false",
            "--entire-thread=false",
            "--body=false",
            "--format=json",
            // Arbitrary limit to prevent too much work
            "--limit=1000",
            "is:unread",
        ])?;
        // Notmuch returns JSON with invalid unicode.  So we lossy convert it to a string here and
        // use that for parsing in rust.
        let s = String::from_utf8_lossy(&slice);
        let mut deserializer = serde_json::Deserializer::from_str(&s);
        deserializer.disable_recursion_limit();
        let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?;
        deserializer.end()?;
        let mut r = HashMap::new();
        fn collect_from_thread_node(
            r: &mut HashMap<String, usize>,
            tn: &ThreadNode,
        ) -> Result<(), NotmuchError> {
            let Some(msg) = &tn.0 else {
                return Ok(());
            };
            let mut addrs = vec![];
            let hdr = &msg.headers.to;
            if let Some(to) = hdr {
                addrs.push(to);
            } else {
                let hdr = &msg.headers.x_original_to;
                if let Some(to) = hdr {
                    addrs.push(to);
                } else {
                    let hdr = &msg.headers.delivered_to;
                    if let Some(to) = hdr {
                        addrs.push(to);
                    };
                };
            };
            let hdr = &msg.headers.cc;
            if let Some(cc) = hdr {
                addrs.push(cc);
            };
            for recipient in addrs {
                mailparse::addrparse(&recipient)?
                    .into_inner()
                    .iter()
                    .for_each(|a| {
                        let mailparse::MailAddr::Single(si) = a else {
                            return;
                        };
                        let addr = &si.addr;

                        if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") {
                            *r.entry(addr.to_lowercase()).or_default() += 1;
                        }
                    });
            }
            Ok(())
        }
        for t in ts.0 {
            for tn in t.0 {
                collect_from_thread_node(&mut r, &tn)?;
                for sub_tn in tn.1 {
                    collect_from_thread_node(&mut r, &sub_tn)?;
                }
            }
        }
        Ok(r)
    }

    fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        let mut cmd = Command::new("notmuch");
        if let Some(config_path) = &self.config_path {
            cmd.arg("--config").arg(config_path);
        }
        cmd.args(args);
        info!("{:?}", &cmd);
        let out = cmd.output()?;
        if !out.stderr.is_empty() {
            warn!(
                "{:?}: STDERR:\n{}",
                &cmd,
                String::from_utf8_lossy(&out.stderr)
            );
        }
        Ok(out.stdout)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[ignore] // skip because notmuch config is relative to $HOME
    fn new() -> Result<(), NotmuchError> {
        let nm = Notmuch::with_config("testdata/notmuch.config");
        nm.new()?;
        let output = nm.no_args()?;
        let s = String::from_utf8_lossy(&output);
        assert!(
            s.contains("Notmuch is configured and appears to have a database. Excellent!"),
            "output:\n```\n{}```",
            s
        );
        Ok(())
    }
    #[test]
    #[ignore] // skip because notmuch config is relative to $HOME
    fn search() -> Result<(), NotmuchError> {
        let nm = Notmuch::with_config("testdata/notmuch.config");
        nm.new()?;
        let res = nm.search("goof", 0, 100)?;
        assert_eq!(res.0.len(), 1);
        Ok(())
    }
    #[test]
    #[ignore] // skip because notmuch config is relative to $HOME
    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]
    #[ignore] // skip because notmuch config is relative to $HOME
    fn count() -> Result<(), NotmuchError> {
        let nm = Notmuch::with_config("testdata/notmuch.config");
        nm.new()?;
        let c = nm.count("*")?;
        assert_eq!(c, 14);
        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: Some(Content::Multipart(vec![
                        Part {
                            id: 2.into(),
                            content_type: "text/plain".to_string(),
                            content_disposition: Some("inline".to_string()),
                            content: Some(Content::String("Spam detection software".to_string())),
                            ..Default::default()
                        },
                        Part {
                            id: 3.into(),
                            content_type: "message/rfc822".to_string(),
                            content_disposition: Some("inline".to_string()),
                            content: Some(Content::Rfc822(vec![Rfc822 {
                                headers: Headers {
                                    subject: "Re: Registration goof".to_string(),
                                    from: "\"Bill Thiede\" <Bill@xinu.tv>".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: Some(Content::String("Hello".to_string())),
                                    ..Default::default()
                                }],
                            }])),
                            ..Default::default()
                        },
                    ])),
                    ..Default::default()
                }]),
                headers: Headers {
                    subject: "Re: Registration goof".to_string(),
                    from: "\"Bill Thiede\" <Bill@xinu.tv>".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);
    }
}
