//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and //! usable by any author-side tool. pub const MAX_CODEPOINTS: usize = 160; pub const MAX_BYTES: usize = 640; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuleError { TooManyCodepoints, TooManyBytes, ForbiddenChar, BadTimestamp, DuplicateTimestamp, } impl std::fmt::Display for RuleError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let msg = match self { RuleError::TooManyCodepoints => "more than 160 Unicode code points", RuleError::TooManyBytes => "more than 640 bytes", RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028", RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision", RuleError::DuplicateTimestamp => "timestamp already exists in this feed", }; f.write_str(msg) } } impl std::error::Error for RuleError {} /// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR /// or U+2028 — any of those would break the one-record-per-line invariant. /// The byte cap is mathematically implied by the codepoint cap over UTF-8 but /// is kept as an explicit guard. pub fn validate_text(text: &str) -> Result<(), RuleError> { if text.chars().count() > MAX_CODEPOINTS { return Err(RuleError::TooManyCodepoints); } if text.len() > MAX_BYTES { return Err(RuleError::TooManyBytes); } if text.contains(['\t', '\n', '\r', '\u{2028}']) { return Err(RuleError::ForbiddenChar); } Ok(()) } /// Rule 1: RFC 3339 at second precision, rendered in UTC with `Z`. pub fn validate_timestamp(timestamp: &str) -> Result<(), RuleError> { if crate::time::parse(timestamp).is_none() { return Err(RuleError::BadTimestamp); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_a_short_post() { assert_eq!(validate_text("hello smolverse"), Ok(())); assert_eq!(validate_timestamp("2026-10-01T12:47:00Z"), Ok(())); } #[test] fn rejects_over_length_text() { let chars: String = std::iter::repeat('a').take(161).collect(); assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints)); // 160 four-byte code points sit exactly on both caps. let four_byte: String = "🙂".repeat(160); assert_eq!(validate_text(&four_byte), Ok(())); let over: String = "🙂".repeat(161); assert_eq!( validate_text(&over), Err(RuleError::TooManyCodepoints), "codepoints are checked before bytes" ); } #[test] fn rejects_record_breaking_characters() { for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] { assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar)); } } #[test] fn rejects_non_z_timestamps() { for bad in ["2026-10-01T12:47:00+03:00", "not a date", ""] { assert_eq!(validate_timestamp(bad), Err(RuleError::BadTimestamp)); } } }