ecr-store 0.2.2

Mail storage for ecr: notmuch queries, MIME parsing and sanitization, sync and send
Documentation
use crate::error::{Error, Result};
use ecr_core::message::TagOp;

pub fn build(ops: &[TagOp]) -> Result<String> {
    let mut batch = String::new();

    for op in ops {
        if op.is_empty() {
            continue;
        }
        for tag in op.add.iter().chain(op.remove.iter()) {
            validate_tag(tag)?;
        }

        for tag in &op.add {
            batch.push('+');
            batch.push_str(&encode_tag(tag));
            batch.push(' ');
        }
        for tag in &op.remove {
            batch.push('-');
            batch.push_str(&encode_tag(tag));
            batch.push(' ');
        }

        batch.push_str("-- ");
        batch.push_str(&op.id.query());
        batch.push('\n');
    }

    Ok(batch)
}

fn validate_tag(tag: &str) -> Result<()> {
    if tag.trim().is_empty() {
        return Err(Error::InvalidTag {
            tag: tag.to_string(),
            reason: "a tag cannot be empty",
        });
    }
    if tag.chars().any(char::is_control) {
        return Err(Error::InvalidTag {
            tag: tag.to_string(),
            reason: "a tag cannot contain control characters",
        });
    }
    if tag.starts_with('-') || tag.starts_with('+') {
        return Err(Error::InvalidTag {
            tag: tag.to_string(),
            reason: "a tag cannot start with '+' or '-'",
        });
    }
    Ok(())
}

fn encode_tag(tag: &str) -> String {
    let mut out = String::with_capacity(tag.len());
    for ch in tag.chars() {
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | '@' | ':') {
            out.push(ch);
        } else {
            let mut buf = [0u8; 4];
            for byte in ch.encode_utf8(&mut buf).as_bytes() {
                out.push_str(&format!("%{byte:02x}"));
            }
        }
    }
    out
}

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

    fn op(id: &str) -> TagOp {
        TagOp::new(MessageId::from(id))
    }

    #[test]
    fn builds_one_line_per_message() {
        let batch = build(&[
            op("a@x").adding("starred").removing("unread"),
            op("b@x").removing("inbox"),
        ])
        .unwrap();

        assert_eq!(
            batch,
            "+starred -unread -- id:\"a@x\"\n-inbox -- id:\"b@x\"\n"
        );
    }

    #[test]
    fn skips_operations_with_nothing_to_do() {
        let batch = build(&[op("a@x"), op("b@x").adding("seen")]).unwrap();
        assert_eq!(batch, "+seen -- id:\"b@x\"\n");
    }

    #[test]
    fn no_operations_produces_an_empty_batch() {
        assert_eq!(build(&[]).unwrap(), "");
    }

    #[test]
    fn a_tag_with_a_space_is_percent_encoded_not_silently_split() {
        let batch = build(&[op("a@x").adding("needs review")]).unwrap();
        assert_eq!(batch, "+needs%20review -- id:\"a@x\"\n");
    }

    #[test]
    fn a_newline_in_a_tag_is_rejected_rather_than_forging_a_batch_line() {
        let err = build(&[op("a@x").adding("evil\n-inbox -- *")]).unwrap_err();
        assert!(matches!(err, Error::InvalidTag { .. }), "{err:?}");
    }

    #[test]
    fn an_empty_tag_is_rejected() {
        assert!(build(&[op("a@x").adding("   ")]).is_err());
    }

    #[test]
    fn a_tag_starting_with_a_sign_is_rejected() {
        assert!(build(&[op("a@x").adding("-inbox")]).is_err());
        assert!(build(&[op("a@x").removing("+inbox")]).is_err());
    }

    #[test]
    fn ordinary_tag_punctuation_survives_untouched() {
        let batch = build(&[op("a@x").adding("lists/rust-dev")]).unwrap();
        assert_eq!(batch, "+lists/rust-dev -- id:\"a@x\"\n");
    }

    #[test]
    fn unicode_tags_are_encoded_bytewise() {
        let batch = build(&[op("a@x").adding("café")]).unwrap();
        assert_eq!(batch, "+caf%c3%a9 -- id:\"a@x\"\n");
    }
}