Skip to main content

max / makenotwork

R27-UX-M3: AppError::Validation now carries per-field errors Closes the last open item from Ultra Fuzz Run 27. The variant changes from `AppError::Validation(String)` to `AppError::Validation(ValidationError)`, where ValidationError carries both a `summary` (the user-visible banner) and an optional `Vec<(field, message)>` so form templates can highlight specific inputs instead of rendering only a global banner. Two constructors keep call-site ergonomics: - `AppError::validation(msg)` — the common path; takes anything that satisfies `Into<ValidationError>` (String, &str, &String). All 187 pre-existing call sites migrate mechanically via: sed 's/AppError::Validation(/AppError::validation(/g' with `.into()` redundancy stripped where the compiler flagged ambiguity. - `AppError::validation_fields(summary, [(field, msg), ...])` — for handlers that know which input was at fault. Templates branch on field-awareness via the new accessor `AppError::validation_fields_ref() -> Option<&[(String, String)]>` — plain summaries return None so the renderer can skip per-field UI. Adopted in `step_account_create` (join wizard), the highest-value multi-field validation site: email/username/password each carry their own field tag now. The full-page rendering path uses validation_fields; the HTMX path keeps the legacy single-line response template for now (separate cleanup). Tests - 7 new unit tests in `error::tests::` covering From impls, with_field chaining, both constructors, and the field accessor's None-on-empty contract. - All pre-existing 187 sites typecheck against the new variant.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 04:28 UTC
Commit: 612deeb56dc07910d302f5401441d5582db135dc
Parent: b7a993b
35 files changed, +387 insertions, -200 deletions
@@ -12,6 +12,61 @@
12 12 #[derive(Clone)]
13 13 pub struct ApiErrorMessage(pub String);
14 14
15 + /// Payload carried by `AppError::Validation`.
16 + ///
17 + /// Always has a `summary` (the user-visible banner above the form). Optionally
18 + /// carries one or more `(field, message)` pairs so form templates can highlight
19 + /// specific inputs instead of rendering only a global banner.
20 + ///
21 + /// Construct via `From<String>` / `From<&str>` when you only have a message,
22 + /// or use `ValidationError::new(summary).with_field(...)` for field-scoped
23 + /// errors. The matching `AppError::validation(msg)` and
24 + /// `AppError::validation_fields(summary, fields)` constructors are the usual
25 + /// entry points from handlers.
26 + #[derive(Debug, Clone, PartialEq, Eq)]
27 + pub struct ValidationError {
28 + pub summary: String,
29 + pub fields: Vec<(String, String)>,
30 + }
31 +
32 + impl ValidationError {
33 + pub fn new(summary: impl Into<String>) -> Self {
34 + Self {
35 + summary: summary.into(),
36 + fields: Vec::new(),
37 + }
38 + }
39 +
40 + pub fn with_field(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
41 + self.fields.push((field.into(), message.into()));
42 + self
43 + }
44 + }
45 +
46 + impl std::fmt::Display for ValidationError {
47 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 + f.write_str(&self.summary)
49 + }
50 + }
51 +
52 + impl From<String> for ValidationError {
53 + fn from(summary: String) -> Self {
54 + ValidationError::new(summary)
55 + }
56 + }
57 +
58 + impl From<&str> for ValidationError {
59 + fn from(summary: &str) -> Self {
60 + ValidationError::new(summary.to_string())
61 + }
62 + }
63 +
64 + impl From<&String> for ValidationError {
65 + fn from(summary: &String) -> Self {
66 + ValidationError::new(summary.clone())
67 + }
68 + }
69 +
15 70 /// Application error type that can be converted into an HTTP response
16 71 #[derive(Debug, thiserror::Error)]
17 72 pub enum AppError {
@@ -28,7 +83,7 @@
28 83 BadRequest(String),
29 84
30 85 #[error("Validation error: {0}")]
31 - Validation(String),
86 + Validation(ValidationError),
32 87
33 88 #[error("Database error: {0}")]
34 89 Database(#[from] sqlx::Error),
@@ -59,6 +114,42 @@
59 114 }
60 115
61 116 impl AppError {
117 + /// Construct a plain validation error with just a summary message.
118 + ///
119 + /// Equivalent to the old `AppError::validation("msg".to_string())` shape —
120 + /// all 187 mechanical migration sites use this. Reach for
121 + /// [`AppError::validation_fields`] instead when you know which input was
122 + /// at fault and want the form template to highlight it.
123 + pub fn validation(summary: impl Into<ValidationError>) -> Self {
124 + AppError::Validation(summary.into())
125 + }
126 +
127 + /// Construct a field-scoped validation error.
128 + ///
129 + /// `summary` is the banner shown above the form; `fields` is a list of
130 + /// `(field_name, message)` pairs the template uses to attach errors to
131 + /// specific inputs. Field names must match the corresponding `<input
132 + /// name=...>` so the template can pair them up.
133 + pub fn validation_fields(
134 + summary: impl Into<String>,
135 + fields: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
136 + ) -> Self {
137 + let mut err = ValidationError::new(summary);
138 + for (k, v) in fields {
139 + err = err.with_field(k, v);
140 + }
141 + AppError::Validation(err)
142 + }
143 +
144 + /// If this error is a validation error with per-field detail, return the
145 + /// field list. Templates use this to render per-input error messages.
146 + pub fn validation_fields_ref(&self) -> Option<&[(String, String)]> {
147 + match self {
148 + AppError::Validation(v) if !v.fields.is_empty() => Some(&v.fields),
149 + _ => None,
150 + }
151 + }
152 +
62 153 /// Static tag for Prometheus metric labels (e.g. `kind="database"`)
63 154 pub fn tag(&self) -> &'static str {
64 155 match self {
@@ -106,7 +197,7 @@
106 197 AppError::Unauthorized => "You need to log in to access this page.".to_string(),
107 198 AppError::Forbidden => "You don't have permission to access this page.".to_string(),
108 199 AppError::BadRequest(msg) => msg.clone(),
109 - AppError::Validation(msg) => msg.clone(),
200 + AppError::Validation(v) => v.summary.clone(),
110 201 AppError::InvalidFileType(msg) => msg.clone(),
111 202 AppError::FileTooLarge(msg) => msg.clone(),
112 203 AppError::MalwareDetected(_) => {
@@ -238,7 +329,7 @@
238 329 #[test]
239 330 fn status_code_validation() {
240 331 assert_eq!(
241 - AppError::Validation("test".into()).status_code(),
332 + AppError::validation("test").status_code(),
242 333 StatusCode::UNPROCESSABLE_ENTITY
243 334 );
244 335 }
@@ -258,7 +349,7 @@
258 349
259 350 #[test]
260 351 fn user_message_validation_passes_through() {
261 - let msg = AppError::Validation("Name too long".into()).user_message();
352 + let msg = AppError::validation("Name too long").user_message();
262 353 assert_eq!(msg, "Name too long");
263 354 }
264 355
@@ -319,7 +410,7 @@
319 410
320 411 #[test]
321 412 fn into_response_validation() {
322 - let (status, body, _) = response_status_and_body(AppError::Validation("too long".into()));
413 + let (status, body, _) = response_status_and_body(AppError::validation("too long"));
323 414 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
324 415 assert_eq!(body, "too long");
325 416 }
@@ -433,7 +524,7 @@
433 524 assert_eq!(AppError::Unauthorized.tag(), "unauthorized");
434 525 assert_eq!(AppError::Forbidden.tag(), "forbidden");
435 526 assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request");
436 - assert_eq!(AppError::Validation("x".into()).tag(), "validation");
527 + assert_eq!(AppError::validation("x").tag(), "validation");
437 528 assert_eq!(AppError::Storage("x".into()).tag(), "storage");
438 529 assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type");
439 530 assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large");
@@ -456,4 +547,70 @@
456 547 assert!(!msg.contains("Win.Trojan"));
457 548 assert!(msg.contains("security scanner"));
458 549 }
550 +
551 + // ── ValidationError + field-scoped Validation ───────────────────────
552 +
553 + #[test]
554 + fn validation_error_from_string_has_no_fields() {
555 + let v: ValidationError = "boom".to_string().into();
556 + assert_eq!(v.summary, "boom");
557 + assert!(v.fields.is_empty());
558 + }
559 +
560 + #[test]
561 + fn validation_error_from_str_has_no_fields() {
562 + let v: ValidationError = "boom".into();
563 + assert_eq!(v.summary, "boom");
564 + assert!(v.fields.is_empty());
565 + }
566 +
567 + #[test]
568 + fn validation_error_with_field_chains() {
569 + let v = ValidationError::new("Please fix the fields")
570 + .with_field("username", "Already taken")
571 + .with_field("email", "Invalid");
572 + assert_eq!(v.summary, "Please fix the fields");
573 + assert_eq!(v.fields.len(), 2);
574 + assert_eq!(v.fields[0], ("username".to_string(), "Already taken".to_string()));
575 + assert_eq!(v.fields[1], ("email".to_string(), "Invalid".to_string()));
576 + }
577 +
578 + #[test]
579 + fn validation_constructor_accepts_plain_string() {
580 + // The most common call shape across the 187 migrated sites.
581 + let e = AppError::validation("nope");
582 + assert_eq!(e.tag(), "validation");
583 + assert_eq!(e.user_message(), "nope");
584 + assert!(e.validation_fields_ref().is_none());
585 + }
586 +
587 + #[test]
588 + fn validation_constructor_accepts_owned_string() {
589 + let e = AppError::validation("nope".to_string());
590 + assert_eq!(e.user_message(), "nope");
591 + }
592 +
593 + #[test]
594 + fn validation_fields_constructor_exposes_field_list() {
595 + let e = AppError::validation_fields(
596 + "Please fix the highlighted fields",
597 + [("username", "Already taken"), ("email", "Invalid")],
598 + );
599 + assert_eq!(e.tag(), "validation");
600 + assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
601 + assert_eq!(e.user_message(), "Please fix the highlighted fields");
602 + let fields = e.validation_fields_ref().expect("fields present");
603 + assert_eq!(fields.len(), 2);
604 + assert_eq!(fields[0].0, "username");
605 + assert_eq!(fields[1].0, "email");
606 + }
607 +
608 + #[test]
609 + fn validation_without_fields_returns_none_from_ref() {
610 + // The accessor is "fields-only" — a plain validation error returns
611 + // None so templates can branch on field-awareness without filtering
612 + // empty vecs.
613 + let e = AppError::validation("just a summary");
614 + assert!(e.validation_fields_ref().is_none());
615 + }
459 616 }
@@ -146,8 +146,8 @@
146 146 if normalized.len() > EMAIL_MAX_LEN
147 147 || !email_address::EmailAddress::is_valid(&normalized)
148 148 {
149 - return Err(AppError::Validation(
150 - "Please enter a valid email address".into(),
149 + return Err(AppError::validation(
150 + "Please enter a valid email address",
151 151 ));
152 152 }
153 153 Ok(Self(normalized))
@@ -388,12 +388,12 @@
388 388 /// Validate and construct. Rejects negative values and values exceeding the cap.
389 389 pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
390 390 if cents < 0 {
391 - return Err(crate::error::AppError::Validation(
391 + return Err(crate::error::AppError::validation(
392 392 "Price cannot be negative".to_string(),
393 393 ));
394 394 }
395 395 if cents > crate::constants::MAX_PRICE_CENTS {
396 - return Err(crate::error::AppError::Validation(
396 + return Err(crate::error::AppError::validation(
397 397 "Price cannot exceed $10,000".to_string(),
398 398 ));
399 399 }
@@ -128,8 +128,8 @@
128 128 }
129 129
130 130 if payload.subscribers.is_empty() && payload.transactions.is_empty() {
131 - return Err(AppError::Validation(
132 - "No valid rows found. Check your column mapping and CSV format.".into(),
131 + return Err(AppError::validation(
132 + "No valid rows found. Check your column mapping and CSV format.",
133 133 ));
134 134 }
135 135
@@ -44,9 +44,9 @@
44 44 /// Validate build_command and artifact_path for shell safety.
45 45 fn validate_build_config_fields(build_command: &str, artifact_path: &str) -> Result<()> {
46 46 crate::build_runner::validate_build_command(build_command)
47 - .map_err(|e| AppError::Validation(format!("build_command: {e}")))?;
47 + .map_err(|e| AppError::validation(format!("build_command: {e}")))?;
48 48 crate::build_runner::validate_artifact_path(artifact_path)
49 - .map_err(|e| AppError::Validation(format!("artifact_path: {e}")))?;
49 + .map_err(|e| AppError::validation(format!("artifact_path: {e}")))?;
50 50 Ok(())
51 51 }
52 52
@@ -7,10 +7,10 @@
7 7 /// Validate an item title
8 8 pub fn validate_item_title(title: &str) -> Result<(), AppError> {
9 9 if title.is_empty() {
10 - return Err(AppError::Validation("Title is required".to_string()));
10 + return Err(AppError::validation("Title is required".to_string()));
11 11 }
12 12 if title.chars().count() > limits::ITEM_TITLE_MAX {
13 - return Err(AppError::Validation(format!(
13 + return Err(AppError::validation(format!(
14 14 "Title must be {} characters or less",
15 15 limits::ITEM_TITLE_MAX
16 16 )));
@@ -21,7 +21,7 @@
21 21 /// Validate an item description
22 22 pub fn validate_item_description(description: &str) -> Result<(), AppError> {
23 23 if description.chars().count() > limits::ITEM_DESCRIPTION_MAX {
24 - return Err(AppError::Validation(format!(
24 + return Err(AppError::validation(format!(
25 25 "Description must be {} characters or less",
26 26 limits::ITEM_DESCRIPTION_MAX
27 27 )));
@@ -32,10 +32,10 @@
32 32 /// Validate a chapter title
33 33 pub fn validate_chapter_title(title: &str) -> Result<(), AppError> {
34 34 if title.is_empty() {
35 - return Err(AppError::Validation("Chapter title is required".to_string()));
35 + return Err(AppError::validation("Chapter title is required".to_string()));
36 36 }
37 37 if title.chars().count() > limits::CHAPTER_TITLE_MAX {
38 - return Err(AppError::Validation(format!(
38 + return Err(AppError::validation(format!(
39 39 "Chapter title must be {} characters or less",
40 40 limits::CHAPTER_TITLE_MAX
41 41 )));
@@ -46,7 +46,7 @@
46 46 /// Validate an item text body
47 47 pub fn validate_item_text_body(body: &str) -> Result<(), AppError> {
48 48 if body.chars().count() > limits::ITEM_TEXT_BODY_MAX {
49 - return Err(AppError::Validation(format!(
49 + return Err(AppError::validation(format!(
50 50 "Text body must be {} characters or less",
51 51 limits::ITEM_TEXT_BODY_MAX
52 52 )));
@@ -60,17 +60,17 @@
60 60 /// so this is only needed when creating new tags.
61 61 pub fn validate_tag_name(name: &str) -> Result<(), AppError> {
62 62 if name.is_empty() {
63 - return Err(AppError::Validation("Tag name cannot be empty".to_string()));
63 + return Err(AppError::validation("Tag name cannot be empty".to_string()));
64 64 }
65 65 if name.chars().count() > limits::TAG_MAX {
66 - return Err(AppError::Validation(format!(
66 + return Err(AppError::validation(format!(
67 67 "Tag name must be {} characters or less",
68 68 limits::TAG_MAX
69 69 )));
70 70 }
71 71 // Tags can only contain alphanumeric characters, spaces, and hyphens
72 72 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') {
73 - return Err(AppError::Validation(
73 + return Err(AppError::validation(
74 74 "Tag names can only contain letters, numbers, spaces, and hyphens".to_string(),
75 75 ));
76 76 }
@@ -90,16 +90,16 @@
90 90
91 91 pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> {
92 92 tagtree::validate_with(slug, &MNW_TAG_CONFIG)
93 - .map_err(|e| AppError::Validation(format!("Invalid tag: {}", e.0)))
93 + .map_err(|e| AppError::validation(format!("Invalid tag: {}", e.0)))
94 94 }
95 95
96 96 /// Validate a version number
97 97 pub fn validate_version_number(version: &str) -> Result<(), AppError> {
98 98 if version.is_empty() {
99 - return Err(AppError::Validation("Version number is required".to_string()));
99 + return Err(AppError::validation("Version number is required".to_string()));
100 100 }
101 101 if version.chars().count() > limits::VERSION_NUMBER_MAX {
102 - return Err(AppError::Validation(format!(
102 + return Err(AppError::validation(format!(
103 103 "Version number must be {} characters or less",
104 104 limits::VERSION_NUMBER_MAX
105 105 )));
@@ -110,7 +110,7 @@
110 110 /// Validate changelog text
111 111 pub fn validate_changelog(changelog: &str) -> Result<(), AppError> {
112 112 if changelog.chars().count() > limits::CHANGELOG_MAX {
113 - return Err(AppError::Validation(format!(
113 + return Err(AppError::validation(format!(
114 114 "Changelog must be {} characters or less",
115 115 limits::CHANGELOG_MAX
116 116 )));
@@ -121,13 +121,13 @@
121 121 /// Validate a waitlist pitch
122 122 pub fn validate_waitlist_pitch(pitch: &str) -> Result<(), AppError> {
123 123 if pitch.chars().count() < limits::WAITLIST_PITCH_MIN {
124 - return Err(AppError::Validation(format!(
124 + return Err(AppError::validation(format!(
125 125 "Pitch must be at least {} characters",
126 126 limits::WAITLIST_PITCH_MIN
127 127 )));
128 128 }
129 129 if pitch.chars().count() > limits::WAITLIST_PITCH_MAX {
130 - return Err(AppError::Validation(format!(
130 + return Err(AppError::validation(format!(
131 131 "Pitch must be {} characters or less",
132 132 limits::WAITLIST_PITCH_MAX
133 133 )));
@@ -138,10 +138,10 @@
138 138 /// Validate a blog post title
139 139 pub fn validate_blog_post_title(title: &str) -> Result<(), AppError> {
140 140 if title.is_empty() {
141 - return Err(AppError::Validation("Blog post title is required".to_string()));
141 + return Err(AppError::validation("Blog post title is required".to_string()));
142 142 }
143 143 if title.chars().count() > limits::BLOG_POST_TITLE_MAX {
144 - return Err(AppError::Validation(format!(
144 + return Err(AppError::validation(format!(
145 145 "Blog post title must be {} characters or less",
146 146 limits::BLOG_POST_TITLE_MAX
147 147 )));
@@ -157,7 +157,7 @@
157 157 /// Validate a blog post body
158 158 pub fn validate_blog_post_body(body: &str) -> Result<(), AppError> {
159 159 if body.chars().count() > limits::BLOG_POST_BODY_MAX {
160 - return Err(AppError::Validation(format!(
160 + return Err(AppError::validation(format!(
161 161 "Blog post body must be {} characters or less",
162 162 limits::BLOG_POST_BODY_MAX
163 163 )));
@@ -168,21 +168,21 @@
168 168 /// Validate a license key code format (word-word-word-word-word)
169 169 pub fn validate_key_code(code: &str) -> Result<(), AppError> {
170 170 if code.is_empty() {
171 - return Err(AppError::Validation("Key code is required".to_string()));
171 + return Err(AppError::validation("Key code is required".to_string()));
172 172 }
173 173 if code.chars().count() > limits::KEY_CODE_MAX {
174 - return Err(AppError::Validation(format!(
174 + return Err(AppError::validation(format!(
175 175 "Key code must be {} characters or less",
176 176 limits::KEY_CODE_MAX
177 177 )));
178 178 }
179 179 let parts: Vec<&str> = code.split('-').collect();
180 180 if parts.len() != 5 {
181 - return Err(AppError::Validation("Invalid key code format".to_string()));
181 + return Err(AppError::validation("Invalid key code format".to_string()));
182 182 }
183 183 for part in &parts {
184 184 if part.is_empty() || !part.chars().all(|c| c.is_ascii_lowercase()) {
185 - return Err(AppError::Validation("Invalid key code format".to_string()));
185 + return Err(AppError::validation("Invalid key code format".to_string()));
186 186 }
187 187 }
188 188 Ok(())
@@ -191,10 +191,10 @@
191 191 /// Validate a collection title
192 192 pub fn validate_collection_title(title: &str) -> Result<(), AppError> {
193 193 if title.is_empty() {
194 - return Err(AppError::Validation("Collection title is required".to_string()));
194 + return Err(AppError::validation("Collection title is required".to_string()));
195 195 }
196 196 if title.chars().count() > limits::COLLECTION_TITLE_MAX {
197 - return Err(AppError::Validation(format!(
197 + return Err(AppError::validation(format!(
198 198 "Collection title must be {} characters or less",
199 199 limits::COLLECTION_TITLE_MAX
200 200 )));
@@ -205,7 +205,7 @@
205 205 /// Validate a collection description
206 206 pub fn validate_collection_description(description: &str) -> Result<(), AppError> {
207 207 if description.chars().count() > limits::COLLECTION_DESCRIPTION_MAX {
208 - return Err(AppError::Validation(format!(
208 + return Err(AppError::validation(format!(
209 209 "Collection description must be {} characters or less",
210 210 limits::COLLECTION_DESCRIPTION_MAX
211 211 )));
@@ -217,10 +217,10 @@
217 217 pub fn validate_section_title(title: &str) -> Result<(), AppError> {
218 218 let trimmed = title.trim();
219 219 if trimmed.is_empty() {
220 - return Err(AppError::Validation("Section title is required".to_string()));
220 + return Err(AppError::validation("Section title is required".to_string()));
221 221 }
222 222 if trimmed.chars().count() > limits::SECTION_TITLE_MAX {
223 - return Err(AppError::Validation(format!(
223 + return Err(AppError::validation(format!(
224 224 "Section title must be {} characters or less",
225 225 limits::SECTION_TITLE_MAX
226 226 )));
@@ -231,7 +231,7 @@
231 231 /// Validate an item section body
232 232 pub fn validate_section_body(body: &str) -> Result<(), AppError> {
233 233 if body.chars().count() > limits::SECTION_BODY_MAX {
234 - return Err(AppError::Validation(format!(
234 + return Err(AppError::validation(format!(
235 235 "Section body must be {} characters or less",
236 236 limits::SECTION_BODY_MAX
237 237 )));
@@ -242,11 +242,11 @@
242 242 /// Validate price in cents (must be non-negative)
243 243 pub fn validate_price_cents(price: i32) -> Result<(), AppError> {
244 244 if price < 0 {
245 - return Err(AppError::Validation("Price cannot be negative".to_string()));
245 + return Err(AppError::validation("Price cannot be negative".to_string()));
246 246 }
247 247 // Cap at $10,000
248 248 if price > constants::MAX_PRICE_CENTS {
249 - return Err(AppError::Validation("Price cannot exceed $10,000".to_string()));
249 + return Err(AppError::validation("Price cannot exceed $10,000".to_string()));
250 250 }
251 251 Ok(())
252 252 }
@@ -72,12 +72,12 @@
72 72 pub fn validate_slug(slug: &str) -> Result<(), AppError> {
73 73 let len = slug.chars().count();
74 74 if len < 2 {
75 - return Err(AppError::Validation(
75 + return Err(AppError::validation(
76 76 "URL name must be at least 2 characters".to_string(),
77 77 ));
78 78 }
79 79 if len > limits::PROJECT_SLUG_MAX {
80 - return Err(AppError::Validation(format!(
80 + return Err(AppError::validation(format!(
81 81 "URL name must be {} characters or less",
82 82 limits::PROJECT_SLUG_MAX
83 83 )));
@@ -86,12 +86,12 @@
86 86 .chars()
87 87 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
88 88 {
89 - return Err(AppError::Validation(
89 + return Err(AppError::validation(
90 90 "URL name can only contain lowercase letters, numbers, and hyphens".to_string(),
91 91 ));
92 92 }
93 93 if !slug.chars().any(|c| c.is_ascii_alphanumeric()) {
94 - return Err(AppError::Validation(
94 + return Err(AppError::validation(
95 95 "URL name must contain at least one letter or number".to_string(),
96 96 ));
97 97 }
@@ -103,10 +103,10 @@
103 103 /// Validate a sync app name
104 104 pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> {
105 105 if name.is_empty() {
106 - return Err(AppError::Validation("App name is required".to_string()));
106 + return Err(AppError::validation("App name is required".to_string()));
107 107 }
108 108 if name.chars().count() > limits::SYNC_APP_NAME_MAX {
109 - return Err(AppError::Validation(format!(
109 + return Err(AppError::validation(format!(
110 110 "App name must be {} characters or less",
111 111 limits::SYNC_APP_NAME_MAX
112 112 )));
@@ -117,10 +117,10 @@
117 117 /// Validate a sync device name
118 118 pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> {
119 119 if name.is_empty() {
120 - return Err(AppError::Validation("Device name is required".to_string()));
120 + return Err(AppError::validation("Device name is required".to_string()));
121 121 }
122 122 if name.chars().count() > limits::SYNC_DEVICE_NAME_MAX {
123 - return Err(AppError::Validation(format!(
123 + return Err(AppError::validation(format!(
124 124 "Device name must be {} characters or less",
125 125 limits::SYNC_DEVICE_NAME_MAX
126 126 )));
@@ -131,16 +131,16 @@
131 131 /// Validate a sync table name
132 132 pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> {
133 133 if name.is_empty() {
134 - return Err(AppError::Validation("Table name is required".to_string()));
134 + return Err(AppError::validation("Table name is required".to_string()));
135 135 }
136 136 if name.chars().count() > limits::SYNC_TABLE_NAME_MAX {
137 - return Err(AppError::Validation(format!(
137 + return Err(AppError::validation(format!(
138 138 "Table name must be {} characters or less",
139 139 limits::SYNC_TABLE_NAME_MAX
140 140 )));
141 141 }
142 142 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
143 - return Err(AppError::Validation(
143 + return Err(AppError::validation(
144 144 "Table name can only contain letters, numbers, and underscores".to_string(),
145 145 ));
146 146 }
@@ -150,10 +150,10 @@
150 150 /// Validate a sync row ID
151 151 pub fn validate_sync_row_id(row_id: &str) -> Result<(), AppError> {
152 152 if row_id.is_empty() {
153 - return Err(AppError::Validation("Row ID is required".to_string()));
153 + return Err(AppError::validation("Row ID is required".to_string()));
154 154 }
155 155 if row_id.chars().count() > limits::SYNC_ROW_ID_MAX {
156 - return Err(AppError::Validation(format!(
156 + return Err(AppError::validation(format!(
157 157 "Row ID must be {} characters or less",
158 158 limits::SYNC_ROW_ID_MAX
159 159 )));
@@ -161,7 +161,7 @@
161 161 // Reject null bytes and control characters — these can cause issues in
162 162 // DB queries, file paths, and log output downstream.
163 163 if row_id.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
164 - return Err(AppError::Validation(
164 + return Err(AppError::validation(
165 165 "Row ID contains invalid characters".to_string(),
166 166 ));
167 167 }
@@ -171,13 +171,13 @@
171 171 /// Validate a sync blob hash (must be exactly 64 lowercase hex characters)
172 172 pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> {
173 173 if hash.len() != limits::SYNC_BLOB_HASH_LEN {
174 - return Err(AppError::Validation(format!(
174 + return Err(AppError::validation(format!(
175 175 "Blob hash must be exactly {} hex characters",
176 176 limits::SYNC_BLOB_HASH_LEN
177 177 )));
178 178 }
179 179 if !hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
180 - return Err(AppError::Validation(
180 + return Err(AppError::validation(
181 181 "Blob hash must be lowercase hexadecimal".to_string(),
182 182 ));
183 183 }
@@ -190,16 +190,16 @@
190 190 /// since downstream goes through SQL and log lines.
191 191 pub fn validate_synckit_key(key: &str) -> Result<(), AppError> {
192 192 if key.is_empty() {
193 - return Err(AppError::Validation("SDK key is required".to_string()));
193 + return Err(AppError::validation("SDK key is required".to_string()));
194 194 }
195 195 if key.len() > limits::SYNC_KEY_MAX {
196 - return Err(AppError::Validation(format!(
196 + return Err(AppError::validation(format!(
197 197 "SDK key must be {} bytes or less",
198 198 limits::SYNC_KEY_MAX
199 199 )));
200 200 }
201 201 if key.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
202 - return Err(AppError::Validation(
202 + return Err(AppError::validation(
203 203 "SDK key contains invalid characters".to_string(),
204 204 ));
205 205 }
@@ -7,10 +7,10 @@
7 7 /// Validate a subscription tier name
8 8 pub fn validate_tier_name(name: &str) -> Result<(), AppError> {
9 9 if name.is_empty() {
10 - return Err(AppError::Validation("Tier name is required".to_string()));
10 + return Err(AppError::validation("Tier name is required".to_string()));
11 11 }
12 12 if name.chars().count() > limits::TIER_NAME_MAX {
13 - return Err(AppError::Validation(format!(
13 + return Err(AppError::validation(format!(
14 14 "Tier name must be {} characters or less",
15 15 limits::TIER_NAME_MAX
16 16 )));
@@ -21,7 +21,7 @@
21 21 /// Validate a subscription tier description
22 22 pub fn validate_tier_description(description: &str) -> Result<(), AppError> {
23 23 if description.chars().count() > limits::TIER_DESCRIPTION_MAX {
24 - return Err(AppError::Validation(format!(
24 + return Err(AppError::validation(format!(
25 25 "Tier description must be {} characters or less",
26 26 limits::TIER_DESCRIPTION_MAX
27 27 )));
@@ -32,13 +32,13 @@
32 32 /// Validate a subscription tier price in cents (must be at least $1.00)
33 33 pub fn validate_tier_price(price_cents: i32) -> Result<(), AppError> {
34 34 if price_cents < crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS {
35 - return Err(AppError::Validation(format!(
35 + return Err(AppError::validation(format!(
36 36 "Subscription price must be at least ${:.2}",
37 37 crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS as f64 / 100.0
38 38 )));
39 39 }
40 40 if price_cents > constants::MAX_PRICE_CENTS {
41 - return Err(AppError::Validation("Subscription price cannot exceed $10,000".to_string()));
41 + return Err(AppError::validation("Subscription price cannot exceed $10,000".to_string()));
42 42 }
43 43 Ok(())
44 44 }
@@ -6,10 +6,10 @@
6 6 /// Validate a project title
7 7 pub fn validate_project_title(title: &str) -> Result<(), AppError> {
8 8 if title.is_empty() {
9 - return Err(AppError::Validation("Project title is required".to_string()));
9 + return Err(AppError::validation("Project title is required".to_string()));
10 10 }
11 11 if title.chars().count() > limits::PROJECT_TITLE_MAX {
12 - return Err(AppError::Validation(format!(
12 + return Err(AppError::validation(format!(
13 13 "Project title must be {} characters or less",
14 14 limits::PROJECT_TITLE_MAX
15 15 )));
@@ -20,7 +20,7 @@
20 20 /// Validate a project description
21 21 pub fn validate_project_description(description: &str) -> Result<(), AppError> {
22 22 if description.chars().count() > limits::PROJECT_DESCRIPTION_MAX {
23 - return Err(AppError::Validation(format!(
23 + return Err(AppError::validation(format!(
24 24 "Project description must be {} characters or less",
25 25 limits::PROJECT_DESCRIPTION_MAX
26 26 )));
@@ -36,10 +36,10 @@
36 36 /// Validate an issue title
37 37 pub fn validate_issue_title(title: &str) -> Result<(), AppError> {
38 38 if title.is_empty() {
39 - return Err(AppError::Validation("Issue title is required".to_string()));
39 + return Err(AppError::validation("Issue title is required".to_string()));
40 40 }
41 41 if title.chars().count() > limits::ISSUE_TITLE_MAX {
42 - return Err(AppError::Validation(format!(
42 + return Err(AppError::validation(format!(
43 43 "Issue title must be {} characters or less",
44 44 limits::ISSUE_TITLE_MAX
45 45 )));
@@ -50,7 +50,7 @@
50 50 /// Validate an issue body (markdown)
51 51 pub fn validate_issue_body(body: &str) -> Result<(), AppError> {
52 52 if body.chars().count() > limits::ISSUE_BODY_MAX {
53 - return Err(AppError::Validation(format!(
53 + return Err(AppError::validation(format!(
54 54 "Issue body must be {} characters or less",
55 55 limits::ISSUE_BODY_MAX
56 56 )));
@@ -61,10 +61,10 @@
61 61 /// Validate an issue comment body (markdown)
62 62 pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> {
63 63 if body.trim().is_empty() {
64 - return Err(AppError::Validation("Comment body is required".to_string()));
64 + return Err(AppError::validation("Comment body is required".to_string()));
65 65 }
66 66 if body.chars().count() > limits::ISSUE_COMMENT_BODY_MAX {
67 - return Err(AppError::Validation(format!(
67 + return Err(AppError::validation(format!(
68 68 "Comment must be {} characters or less",
69 69 limits::ISSUE_COMMENT_BODY_MAX
70 70 )));
@@ -75,10 +75,10 @@
75 75 /// Validate an issue label name
76 76 pub fn validate_label_name(name: &str) -> Result<(), AppError> {
77 77 if name.trim().is_empty() {
78 - return Err(AppError::Validation("Label name is required".to_string()));
78 + return Err(AppError::validation("Label name is required".to_string()));
79 79 }
80 80 if name.chars().count() > limits::ISSUE_LABEL_NAME_MAX {
81 - return Err(AppError::Validation(format!(
81 + return Err(AppError::validation(format!(
82 82 "Label name must be {} characters or less",
83 83 limits::ISSUE_LABEL_NAME_MAX
84 84 )));
@@ -86,7 +86,7 @@
86 86 // Only allow printable characters (letters, numbers, punctuation, spaces).
87 87 // Rejects control characters, null bytes, and non-printable unicode.
88 88 if name.chars().any(|c| c.is_control()) {
89 - return Err(AppError::Validation(
89 + return Err(AppError::validation(
90 90 "Label name cannot contain control characters".to_string(),
91 91 ));
92 92 }
@@ -96,12 +96,12 @@
96 96 /// Validate a label color (hex format: #RRGGBB)
97 97 pub fn validate_label_color(color: &str) -> Result<(), AppError> {
98 98 if color.len() != 7 || !color.starts_with('#') {
99 - return Err(AppError::Validation(
99 + return Err(AppError::validation(
100 100 "Label color must be in #RRGGBB format".to_string(),
101 101 ));
102 102 }
103 103 if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) {
104 - return Err(AppError::Validation(
104 + return Err(AppError::validation(
105 105 "Label color must be a valid hex color".to_string(),
106 106 ));
107 107 }
@@ -111,7 +111,7 @@
111 111 /// Validate a git repo description (length check, trimmed input expected).
112 112 pub fn validate_repo_description(desc: &str) -> Result<(), AppError> {
113 113 if desc.chars().count() > limits::REPO_DESCRIPTION_MAX {
114 - return Err(AppError::Validation(format!(
114 + return Err(AppError::validation(format!(
115 115 "Repository description must be {} characters or less",
116 116 limits::REPO_DESCRIPTION_MAX
117 117 )));
@@ -122,12 +122,12 @@
122 122 /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot.
123 123 pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> {
124 124 if name.is_empty() || name.len() > 64 {
125 - return Err(AppError::Validation(
125 + return Err(AppError::validation(
126 126 "Git repo name must be 1-64 characters".to_string(),
127 127 ));
128 128 }
129 129 if name.starts_with('.') {
130 - return Err(AppError::Validation(
130 + return Err(AppError::validation(
131 131 "Git repo name cannot start with a dot".to_string(),
132 132 ));
133 133 }
@@ -135,7 +135,7 @@
135 135 .chars()
136 136 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
137 137 {
138 - return Err(AppError::Validation(
138 + return Err(AppError::validation(
139 139 "Git repo name can only contain letters, numbers, hyphens, underscores, and dots"
140 140 .to_string(),
141 141 ));
@@ -6,7 +6,7 @@
6 6 /// Validate a display name
7 7 pub fn validate_display_name(name: &str) -> Result<(), AppError> {
8 8 if name.chars().count() > limits::DISPLAY_NAME_MAX {
9 - return Err(AppError::Validation(format!(
9 + return Err(AppError::validation(format!(
10 10 "Display name must be {} characters or less",
11 11 limits::DISPLAY_NAME_MAX
12 12 )));
@@ -14,7 +14,7 @@
14 14 // Reject control characters (ASCII 0-31 except space, plus DEL 0x7F)
15 15 // to prevent social engineering in plain-text emails.
16 16 if name.chars().any(|c| c.is_control()) {
17 - return Err(AppError::Validation(
17 + return Err(AppError::validation(
18 18 "Display name cannot contain control characters".to_string(),
19 19 ));
20 20 }
@@ -24,7 +24,7 @@
24 24 /// Validate a bio
25 25 pub fn validate_bio(bio: &str) -> Result<(), AppError> {
26 26 if bio.chars().count() > limits::BIO_MAX {
27 - return Err(AppError::Validation(format!(
27 + return Err(AppError::validation(format!(
28 28 "Bio must be {} characters or less",
29 29 limits::BIO_MAX
30 30 )));
@@ -35,7 +35,7 @@
35 35 /// Validate a link URL
36 36 pub fn validate_link_url(url_str: &str) -> Result<(), AppError> {
37 37 if url_str.chars().count() > limits::LINK_URL_MAX {
38 - return Err(AppError::Validation(format!(
38 + return Err(AppError::validation(format!(
39 39 "URL must be {} characters or less",
40 40 limits::LINK_URL_MAX
41 41 )));
@@ -43,13 +43,13 @@
43 43
44 44 // Parse URL properly to prevent malformed/malicious URLs
45 45 let parsed = url::Url::parse(url_str)
46 - .map_err(|_| AppError::Validation("Invalid URL format".to_string()))?;
46 + .map_err(|_| AppError::validation("Invalid URL format".to_string()))?;
47 47
48 48 // Only allow http and https schemes
49 49 match parsed.scheme() {
50 50 "http" | "https" => {}
51 51 _ => {
52 - return Err(AppError::Validation(
52 + return Err(AppError::validation(
53 53 "URL must use http:// or https://".to_string(),
54 54 ));
55 55 }
@@ -57,7 +57,7 @@
57 57
58 58 // Must have a host
59 59 if parsed.host_str().is_none() {
60 - return Err(AppError::Validation("URL must have a host".to_string()));
60 + return Err(AppError::validation("URL must have a host".to_string()));
61 61 }
62 62
63 63 Ok(())
@@ -66,10 +66,10 @@
66 66 /// Validate a link title
67 67 pub fn validate_link_title(title: &str) -> Result<(), AppError> {
68 68 if title.is_empty() {
69 - return Err(AppError::Validation("Link title is required".to_string()));
69 + return Err(AppError::validation("Link title is required".to_string()));
70 70 }
71 71 if title.chars().count() > limits::LINK_TITLE_MAX {
72 - return Err(AppError::Validation(format!(
72 + return Err(AppError::validation(format!(
73 73 "Link title must be {} characters or less",
74 74 limits::LINK_TITLE_MAX
75 75 )));
@@ -83,17 +83,17 @@
83 83 pub fn validate_username(username: &str) -> Result<(), AppError> {
84 84 let len = username.chars().count();
85 85 if len < 3 {
86 - return Err(AppError::Validation(
86 + return Err(AppError::validation(
87 87 "Username must be at least 3 characters".to_string(),
88 88 ));
89 89 }
90 90 if len > 50 {
91 - return Err(AppError::Validation(
91 + return Err(AppError::validation(
92 92 "Username must be 50 characters or less".to_string(),
93 93 ));
94 94 }
95 95 if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
96 - return Err(AppError::Validation(
96 + return Err(AppError::validation(
97 97 "Username can only contain letters, numbers, and underscores".to_string(),
98 98 ));
99 99 }
@@ -103,10 +103,10 @@
103 103 /// Validate a machine ID
104 104 pub fn validate_machine_id(machine_id: &str) -> Result<(), AppError> {
105 105 if machine_id.is_empty() {
106 - return Err(AppError::Validation("Machine ID is required".to_string()));
106 + return Err(AppError::validation("Machine ID is required".to_string()));
107 107 }
108 108 if machine_id.chars().count() > limits::MACHINE_ID_MAX {
109 - return Err(AppError::Validation(format!(
109 + return Err(AppError::validation(format!(
110 110 "Machine ID must be {} characters or less",
111 111 limits::MACHINE_ID_MAX
112 112 )));
@@ -117,7 +117,7 @@
117 117 /// Validate an activation label
118 118 pub fn validate_activation_label(label: &str) -> Result<(), AppError> {
119 119 if label.chars().count() > limits::ACTIVATION_LABEL_MAX {
120 - return Err(AppError::Validation(format!(
120 + return Err(AppError::validation(format!(
121 121 "Label must be {} characters or less",
122 122 limits::ACTIVATION_LABEL_MAX
123 123 )));
@@ -147,16 +147,16 @@
147 147 let input = input.trim();
148 148
149 149 if input.is_empty() {
150 - return Err(AppError::Validation("SSH public key is required".to_string()));
150 + return Err(AppError::validation("SSH public key is required".to_string()));
151 151 }
152 152
153 153 if input.len() > 8192 {
154 - return Err(AppError::Validation("SSH public key is too large".to_string()));
154 + return Err(AppError::validation("SSH public key is too large".to_string()));
155 155 }
156 156
157 157 let parts: Vec<&str> = input.split_whitespace().collect();
158 158 if parts.len() < 2 {
159 - return Err(AppError::Validation(
159 + return Err(AppError::validation(
160 160 "Invalid SSH key format: expected '{type} {base64} [comment]'".to_string(),
161 161 ));
162 162 }
@@ -165,7 +165,7 @@
165 165 let key_data = parts[1];
166 166
167 167 if !SSH_KEY_TYPES.contains(&key_type) {
168 - return Err(AppError::Validation(format!(
168 + return Err(AppError::validation(format!(
169 169 "Unsupported SSH key type '{}'. Accepted: ssh-rsa, ssh-ed25519, ecdsa-sha2-*",
170 170 key_type
171 171 )));
@@ -175,10 +175,10 @@
175 175 use base64::Engine;
176 176 let decoded = base64::engine::general_purpose::STANDARD
177 177 .decode(key_data)
178 - .map_err(|_| AppError::Validation("Invalid SSH key: bad base64 encoding".to_string()))?;
178 + .map_err(|_| AppError::validation("Invalid SSH key: bad base64 encoding".to_string()))?;
179 179
180 180 if decoded.len() < 16 {
181 - return Err(AppError::Validation("Invalid SSH key: data too short".to_string()));
181 + return Err(AppError::validation("Invalid SSH key: data too short".to_string()));
182 182 }
183 183
184 184 // Compute fingerprint: SHA256:{base64(sha256(decoded))} (same as ssh-keygen -lf)
@@ -198,7 +198,7 @@
198 198 /// Validate an SSH key label
199 199 pub fn validate_ssh_key_label(label: &str) -> std::result::Result<(), AppError> {
200 200 if label.chars().count() > limits::SSH_KEY_LABEL_MAX {
201 - return Err(AppError::Validation(format!(
201 + return Err(AppError::validation(format!(
202 202 "SSH key label must be {} characters or less",
203 203 limits::SSH_KEY_LABEL_MAX
204 204 )));
@@ -78,7 +78,7 @@
78 78 AdminUser(_admin): AdminUser,
79 79 ) -> Result<Response> {
80 80 let Some(ref mt) = state.mt_client else {
81 - return Err(AppError::Validation("MT integration not configured".to_string()));
81 + return Err(AppError::validation("MT integration not configured".to_string()));
82 82 };
83 83
84 84 let projects = db::projects::get_projects_without_mt_community(&state.db).await?;
@@ -155,7 +155,7 @@
155 155 ) -> Result<Response> {
156 156 let shutdown_date = form.shutdown_date.trim();
157 157 if shutdown_date.is_empty() {
158 - return Err(AppError::Validation("Shutdown date is required".to_string()));
158 + return Err(AppError::validation("Shutdown date is required".to_string()));
159 159 }
160 160
161 161 let all_users = db::users::get_all_user_emails(&state.db).await?;