daaki-imap 0.1.0

An IMAP4rev1/IMAP4rev2 async client library
Documentation
//! IMAP message flags (RFC 3501 Section 2.3.2, RFC 9051 Section 2.3.2).

/// An IMAP message flag (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
///
/// System flags are modeled as enum variants; server-specific keywords
/// are captured in `Custom`.
#[derive(Debug, Clone)]
pub enum Flag {
    /// `\Seen` — Message has been read (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Seen,
    /// `\Answered` — Message has been replied to (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Answered,
    /// `\Flagged` — Message is "flagged" / starred (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Flagged,
    /// `\Deleted` — Message is marked for deletion (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Deleted,
    /// `\Draft` — Message is a draft (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Draft,
    /// `\Recent` — Message is "recent" (RFC 3501 Section 2.3.2 only, read-only, removed in RFC 9051).
    Recent,
    /// Permanent flag wildcard (\*), meaning client can create new keywords (RFC 3501 Section 7.1).
    Wildcard,
    /// A keyword flag (e.g. `$Important`, `$Junk`, `NonJunk`, or any custom keyword)
    /// (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    Custom(String),
}

impl Flag {
    /// Returns the wire representation of this flag (e.g. `\Seen`, `$Important`)
    /// (RFC 3501 Section 2.3.2 / RFC 9051 Section 2.3.2).
    pub fn as_imap_str(&self) -> &str {
        match self {
            Self::Seen => "\\Seen",
            Self::Answered => "\\Answered",
            Self::Flagged => "\\Flagged",
            Self::Deleted => "\\Deleted",
            Self::Draft => "\\Draft",
            Self::Recent => "\\Recent",
            Self::Wildcard => "\\*",
            Self::Custom(s) => s,
        }
    }

    /// Parse a flag from its wire representation
    /// (RFC 3501 Section 2.3.2, case-insensitive per RFC 9051 Section 2.3.2).
    pub fn from_imap_str(s: &str) -> Self {
        // RFC 3501: flag comparisons are case-insensitive.
        let lower = s.to_ascii_lowercase();
        match lower.as_str() {
            "\\seen" => Self::Seen,
            "\\answered" => Self::Answered,
            "\\flagged" => Self::Flagged,
            "\\deleted" => Self::Deleted,
            "\\draft" => Self::Draft,
            "\\recent" => Self::Recent,
            "\\*" => Self::Wildcard,
            _ => Self::Custom(s.to_owned()),
        }
    }
}

impl std::fmt::Display for Flag {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_imap_str())
    }
}

/// RFC 3501 Section 2.3.2: "Flag names are case-insensitive."
///
/// System flag variants compare by discriminant (they have no payload).
/// `Custom` flags compare using ASCII case-insensitive comparison so that
/// e.g. `$Important` and `$important` are treated as the same flag.
///
/// Cross-representation is also handled: `Custom("\\Seen")` equals `Seen`,
/// because they denote the same protocol flag.
impl PartialEq for Flag {
    fn eq(&self, other: &Self) -> bool {
        // RFC 3501 Section 2.3.2: flag comparisons are case-insensitive.
        match (self, other) {
            (Self::Seen, Self::Seen)
            | (Self::Answered, Self::Answered)
            | (Self::Flagged, Self::Flagged)
            | (Self::Deleted, Self::Deleted)
            | (Self::Draft, Self::Draft)
            | (Self::Recent, Self::Recent)
            | (Self::Wildcard, Self::Wildcard) => true,
            (Self::Custom(a), Self::Custom(b)) => a.eq_ignore_ascii_case(b),
            // Cross-representation: compare Custom's wire form against known variant.
            (Self::Custom(s), known) | (known, Self::Custom(s)) => {
                s.eq_ignore_ascii_case(known.as_imap_str())
            }
            _ => false,
        }
    }
}

/// RFC 3501 Section 2.3.2: flag equality is reflexive, symmetric, transitive.
impl Eq for Flag {}

/// RFC 3501 Section 2.3.2: "Flag names are case-insensitive."
///
/// The `Hash` implementation must be consistent with `PartialEq`: flags that
/// compare equal must hash to the same value. Because `Custom("\\Seen")` must
/// equal `Seen`, we hash the lowercased wire form (`as_imap_str()`) for all
/// variants, which is identical for cross-representation equivalents.
impl std::hash::Hash for Flag {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        // RFC 3501 Section 2.3.2: case-insensitive hashing via wire form.
        // Custom("\\Seen") and Seen both yield "\\Seen", so lowercasing
        // produces the same hash.
        for byte in self.as_imap_str().as_bytes() {
            byte.to_ascii_lowercase().hash(state);
        }
    }
}

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

    #[test]
    fn system_flags_round_trip() {
        let flags = [
            Flag::Seen,
            Flag::Answered,
            Flag::Flagged,
            Flag::Deleted,
            Flag::Draft,
            Flag::Recent,
        ];
        for flag in &flags {
            let s = flag.as_imap_str();
            let parsed = Flag::from_imap_str(s);
            assert_eq!(*flag, parsed);
        }
    }

    #[test]
    fn case_insensitive_parsing() {
        assert_eq!(Flag::from_imap_str("\\SEEN"), Flag::Seen);
        assert_eq!(Flag::from_imap_str("\\seen"), Flag::Seen);
        assert_eq!(Flag::from_imap_str("\\Seen"), Flag::Seen);
    }

    #[test]
    fn custom_flag_preserved() {
        let flag = Flag::from_imap_str("$Important");
        assert_eq!(flag, Flag::Custom("$Important".into()));
    }

    // ===== Spec audit: failing tests for known deviations =====

    #[test]
    fn spec_audit_l1_permanent_flag_wildcard_has_dedicated_variant() {
        // RFC 3501 Section 7.1: `flag-perm = flag / "\*"` — the wildcard
        // means "client can create new keywords." It deserves a dedicated
        // variant (e.g. Flag::Wildcard) rather than falling through to
        // Flag::Custom("\\*").
        let flag = Flag::from_imap_str("\\*");
        assert!(
            !matches!(flag, Flag::Custom(_)),
            "\\* should have a dedicated Flag variant, not Flag::Custom; got {flag:?}"
        );
        assert_eq!(flag, Flag::Wildcard);
    }

    #[test]
    fn wildcard_round_trip() {
        // RFC 3501 Section 7.1: `flag-perm = flag / "\*"`
        let flag = Flag::Wildcard;
        let wire = flag.as_imap_str();
        assert_eq!(wire, "\\*");
        let parsed = Flag::from_imap_str(wire);
        assert_eq!(parsed, Flag::Wildcard);
    }

    // ===== Case-insensitive Custom flag equality (RFC 3501 Section 2.3.2) =====

    #[test]
    fn custom_flag_equality_is_case_insensitive() {
        // RFC 3501 Section 2.3.2: "A flag can be permanent or session-only…
        // Flag names are case-insensitive."
        assert_eq!(
            Flag::Custom("$Important".into()),
            Flag::Custom("$important".into()),
            "Custom flags must compare case-insensitively per RFC 3501 Section 2.3.2"
        );
        assert_eq!(
            Flag::Custom("$JUNK".into()),
            Flag::Custom("$Junk".into()),
            "Custom flags must compare case-insensitively per RFC 3501 Section 2.3.2"
        );
    }

    #[test]
    fn custom_flag_hash_is_case_insensitive() {
        // RFC 3501 Section 2.3.2: Flag names are case-insensitive, so
        // case-different Custom flags must hash to the same bucket.
        use std::collections::HashSet;

        let mut set = HashSet::new();
        set.insert(Flag::Custom("$Important".into()));
        // Inserting the same flag in different case should not grow the set.
        set.insert(Flag::Custom("$important".into()));
        assert_eq!(
            set.len(),
            1,
            "Case-insensitively equal Custom flags must have the same Hash per RFC 3501 Section 2.3.2"
        );
    }

    // ===== RFC 3501 Section 2.3.2 audit: cross-representation equality =====

    #[test]
    fn custom_seen_equals_seen_variant() {
        // RFC 3501 Section 2.3.2: "Flag names are case-insensitive."
        // Custom("\\Seen") represents the same flag as Flag::Seen.
        assert_eq!(
            Flag::Custom("\\Seen".into()),
            Flag::Seen,
            "Custom(\"\\\\Seen\") must equal Flag::Seen per RFC 3501 Section 2.3.2"
        );
    }

    #[test]
    fn custom_system_flag_cross_representation_hash() {
        // RFC 3501 Section 2.3.2: equal flags must hash identically.
        use std::collections::HashSet;
        let mut set = HashSet::new();
        set.insert(Flag::Seen);
        set.insert(Flag::Custom("\\Seen".into()));
        assert_eq!(
            set.len(),
            1,
            "Custom(\"\\\\Seen\") and Flag::Seen must hash the same per RFC 3501 Section 2.3.2"
        );
    }

    #[test]
    fn custom_wildcard_equals_wildcard_variant() {
        // RFC 3501 Section 7.1: permanent flag wildcard.
        assert_eq!(
            Flag::Custom("\\*".into()),
            Flag::Wildcard,
            "Custom(\"\\\\*\") must equal Flag::Wildcard per RFC 3501 Section 7.1"
        );
    }
}