Skip to main content

max / makenotwork

tagtree: convert TagError from a String newtype to an enum Replace TagError(pub String) with a seven-variant enum carrying typed fields (TooLong{max}, TooDeep{levels,max}, InvalidChar(char), ...). Display now renders the bare message (no "invalid tag:" prefix), matching what callers previously read from .0, so the server and multithreaded consumers switch from e.0 to the Display form with identical output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 01:50 UTC
Commit: 5ad8b9dfa692e095963d691d53337f3510d3c18b
Parent: b17af87
3 files changed, +48 insertions, -26 deletions
@@ -303,7 +303,7 @@ pub(super) async fn create_tag_handler(
303 303
304 304 let tag_slug = form.slug.trim().to_lowercase();
305 305 tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| {
306 - (StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid tag slug: {}", e.0)).into_response()
306 + (StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid tag slug: {}", e)).into_response()
307 307 })?;
308 308
309 309 mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug)
@@ -93,7 +93,7 @@ pub const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
93 93
94 94 pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> {
95 95 tagtree::validate_with(slug, &MNW_TAG_CONFIG)
96 - .map_err(|e| AppError::validation(format!("Invalid tag: {}", e.0)))
96 + .map_err(|e| AppError::validation(format!("Invalid tag: {}", e)))
97 97 }
98 98
99 99 /// Validate a version number
@@ -62,11 +62,42 @@ pub struct TagConfig {
62 62
63 63 /// Error returned when a tag string is invalid.
64 64 #[derive(Debug, Clone, PartialEq, Eq)]
65 - pub struct TagError(pub String);
65 + pub enum TagError {
66 + /// The tag was empty.
67 + Empty,
68 + /// The tag exceeded the configured maximum length (in characters).
69 + TooLong { max: usize },
70 + /// The tag started or ended with a separator dot.
71 + LeadingOrTrailingDot,
72 + /// The tag contained consecutive separator dots.
73 + ConsecutiveDots,
74 + /// The tag contained a character outside the allowed set.
75 + InvalidChar(char),
76 + /// The tag had more segments than the configured maximum depth.
77 + TooDeep { levels: usize, max: usize },
78 + /// The tag had fewer segments than the configured semantic prefix requires.
79 + InsufficientDepth { min_segments: usize, semantic_depth: usize },
80 + }
66 81
67 82 impl fmt::Display for TagError {
68 83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 - write!(f, "invalid tag: {}", self.0)
84 + match self {
85 + Self::Empty => write!(f, "tag cannot be empty"),
86 + Self::TooLong { max } => write!(f, "tag exceeds {max} characters"),
87 + Self::LeadingOrTrailingDot => write!(f, "tag cannot start or end with a dot"),
88 + Self::ConsecutiveDots => write!(f, "tag cannot contain consecutive dots"),
89 + Self::InvalidChar(ch) => write!(
90 + f,
91 + "invalid character '{ch}': only lowercase alphanumeric, hyphens, and dots allowed"
92 + ),
93 + Self::TooDeep { levels, max } => {
94 + write!(f, "tag has {levels} levels, maximum is {max}")
95 + }
96 + Self::InsufficientDepth { min_segments, semantic_depth } => write!(
97 + f,
98 + "tag must have at least {min_segments} segments (semantic prefix requires {semantic_depth} + at least 1 value)"
99 + ),
100 + }
70 101 }
71 102 }
72 103
@@ -93,40 +124,31 @@ impl std::error::Error for TagError {}
93 124 /// ```
94 125 pub fn validate_with(tag: &str, config: &TagConfig) -> Result<(), TagError> {
95 126 if tag.is_empty() {
96 - return Err(TagError("tag cannot be empty".into()));
127 + return Err(TagError::Empty);
97 128 }
98 129 if tag.len() > config.max_length {
99 - return Err(TagError(format!(
100 - "tag exceeds {} characters",
101 - config.max_length
102 - )));
130 + return Err(TagError::TooLong { max: config.max_length });
103 131 }
104 132 if tag.starts_with(SEPARATOR) || tag.ends_with(SEPARATOR) {
105 - return Err(TagError("tag cannot start or end with a dot".into()));
133 + return Err(TagError::LeadingOrTrailingDot);
106 134 }
107 135 if tag.contains("..") {
108 - return Err(TagError("tag cannot contain consecutive dots".into()));
136 + return Err(TagError::ConsecutiveDots);
109 137 }
110 138 for ch in tag.chars() {
111 139 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == SEPARATOR) {
112 - return Err(TagError(format!(
113 - "invalid character '{ch}': only lowercase alphanumeric, hyphens, and dots allowed"
114 - )));
140 + return Err(TagError::InvalidChar(ch));
115 141 }
116 142 }
117 143 let d = depth(tag);
118 144 if d > config.max_depth {
119 - return Err(TagError(format!(
120 - "tag has {} levels, maximum is {}",
121 - d, config.max_depth
122 - )));
145 + return Err(TagError::TooDeep { levels: d, max: config.max_depth });
123 146 }
124 147 if config.semantic_depth > 0 && d < config.semantic_depth + 1 {
125 - return Err(TagError(format!(
126 - "tag must have at least {} segments (semantic prefix requires {} + at least 1 value)",
127 - config.semantic_depth + 1,
128 - config.semantic_depth
129 - )));
148 + return Err(TagError::InsufficientDepth {
149 + min_segments: config.semantic_depth + 1,
150 + semantic_depth: config.semantic_depth,
151 + });
130 152 }
131 153 Ok(())
132 154 }
@@ -1880,10 +1902,10 @@ mod tests {
1880 1902 fn tag_error_display_includes_message() {
1881 1903 // Catches `<impl fmt::Display for TagError>::fmt -> Ok(Default::default())`.
1882 1904 // Without this test, the Display impl was never exercised.
1883 - let err = TagError("bad thing".into());
1905 + let err = TagError::TooLong { max: 50 };
1884 1906 let s = format!("{err}");
1885 - assert!(s.contains("bad thing"), "display output: {s:?}");
1886 - assert!(s.starts_with("invalid tag:"), "display output: {s:?}");
1907 + assert!(s.contains("50 characters"), "display output: {s:?}");
1908 + assert!(!s.is_empty(), "display output: {s:?}");
1887 1909 }
1888 1910
1889 1911 #[test]