1use std::borrow::Cow;
4use std::fmt;
5
6use nutype::nutype;
7use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
8
9pub const CHALLENGE_NAME_ERROR_MESSAGE: &str = "challenge_name must be 3-63 lowercase ASCII letters, digits, or single hyphens, and must start and end with a letter or digit";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct ChallengeNameError;
15
16impl fmt::Display for ChallengeNameError {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 f.write_str(CHALLENGE_NAME_ERROR_MESSAGE)
20 }
21}
22
23impl std::error::Error for ChallengeNameError {}
24
25pub const TARGET_NAME_ERROR_MESSAGE: &str = "target must be non-empty and contain only ASCII letters, digits, underscores, hyphens, or dots";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct TargetNameError;
31
32impl fmt::Display for TargetNameError {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.write_str(TARGET_NAME_ERROR_MESSAGE)
36 }
37}
38
39impl std::error::Error for TargetNameError {}
40
41pub const METRIC_NAME_ERROR_MESSAGE: &str = "metric_name must be non-empty and contain only ASCII letters, digits, underscores, hyphens, or dots";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct MetricNameError;
47
48impl fmt::Display for MetricNameError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.write_str(METRIC_NAME_ERROR_MESSAGE)
52 }
53}
54
55impl std::error::Error for MetricNameError {}
56
57pub const ASSET_NAME_ERROR_MESSAGE: &str = "asset_name must be non-empty and contain only ASCII letters, digits, underscores, hyphens, or dots";
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct AssetNameError;
63
64impl fmt::Display for AssetNameError {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str(ASSET_NAME_ERROR_MESSAGE)
68 }
69}
70
71impl std::error::Error for AssetNameError {}
72
73pub const RUN_NAME_ERROR_MESSAGE: &str = "run_name must be non-empty, must not be `.` or `..`, and must contain only ASCII letters, digits, underscores, hyphens, or dots";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct RunNameError;
79
80impl fmt::Display for RunNameError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.write_str(RUN_NAME_ERROR_MESSAGE)
84 }
85}
86
87impl std::error::Error for RunNameError {}
88
89pub const RESOURCE_PROFILE_NAME_ERROR_MESSAGE: &str = "resource_profile.name must be non-empty and contain only ASCII letters, digits, underscores, hyphens, or dots";
91
92pub const CHALLENGE_KEYWORD_ERROR_MESSAGE: &str = "challenge keyword must be non-empty after trimming, at most 30 UTF-8 bytes, and must not contain control characters";
94
95pub const MOLTBOOK_SUBMOLT_NAME_ERROR_MESSAGE: &str = "moltbook submolt name must be 2-30 lowercase ASCII letters, digits, or single hyphens, and must start and end with a letter or digit";
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct ResourceProfileNameError;
101
102impl fmt::Display for ResourceProfileNameError {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(RESOURCE_PROFILE_NAME_ERROR_MESSAGE)
106 }
107}
108
109impl std::error::Error for ResourceProfileNameError {}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct ChallengeKeywordError;
114
115impl fmt::Display for ChallengeKeywordError {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str(CHALLENGE_KEYWORD_ERROR_MESSAGE)
119 }
120}
121
122impl std::error::Error for ChallengeKeywordError {}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct MoltbookSubmoltNameError;
127
128impl fmt::Display for MoltbookSubmoltNameError {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.write_str(MOLTBOOK_SUBMOLT_NAME_ERROR_MESSAGE)
132 }
133}
134
135impl std::error::Error for MoltbookSubmoltNameError {}
136
137#[nutype(
138 sanitize(trim, lowercase),
139 validate(with = validate_challenge_name, error = ChallengeNameError),
140 derive(
141 Debug,
142 Clone,
143 PartialEq,
144 Eq,
145 PartialOrd,
146 Ord,
147 Hash,
148 AsRef,
149 Deref,
150 Display,
151 Serialize,
152 Deserialize,
153 FromStr,
154 TryFrom,
155 ),
156)]
157pub struct ChallengeName(String);
159
160impl ChallengeName {
161 pub fn as_str(&self) -> &str {
163 self.as_ref()
164 }
165}
166
167#[nutype(
168 validate(with = validate_target_name, error = TargetNameError),
169 derive(
170 Debug,
171 Clone,
172 PartialEq,
173 Eq,
174 PartialOrd,
175 Ord,
176 Hash,
177 AsRef,
178 Deref,
179 Display,
180 Serialize,
181 Deserialize,
182 FromStr,
183 TryFrom,
184 ),
185)]
186pub struct TargetName(String);
188
189impl TargetName {
190 pub fn as_str(&self) -> &str {
192 self.as_ref()
193 }
194}
195
196#[nutype(
197 validate(with = validate_asset_name, error = AssetNameError),
198 derive(
199 Debug,
200 Clone,
201 PartialEq,
202 Eq,
203 PartialOrd,
204 Ord,
205 Hash,
206 AsRef,
207 Deref,
208 Display,
209 Serialize,
210 Deserialize,
211 FromStr,
212 TryFrom,
213 ),
214)]
215pub struct AssetName(String);
217
218impl AssetName {
219 pub fn as_str(&self) -> &str {
221 self.as_ref()
222 }
223}
224
225#[nutype(
226 validate(with = validate_run_name, error = RunNameError),
227 derive(
228 Debug,
229 Clone,
230 PartialEq,
231 Eq,
232 PartialOrd,
233 Ord,
234 Hash,
235 AsRef,
236 Deref,
237 Display,
238 Serialize,
239 Deserialize,
240 FromStr,
241 TryFrom,
242 ),
243)]
244pub struct RunName(String);
246
247impl RunName {
248 pub fn as_str(&self) -> &str {
250 self.as_ref()
251 }
252}
253
254#[nutype(
255 validate(
256 with = validate_resource_profile_name,
257 error = ResourceProfileNameError
258 ),
259 derive(
260 Debug,
261 Clone,
262 PartialEq,
263 Eq,
264 PartialOrd,
265 Ord,
266 Hash,
267 AsRef,
268 Deref,
269 Display,
270 Serialize,
271 Deserialize,
272 FromStr,
273 TryFrom,
274 ),
275)]
276pub struct ResourceProfileName(String);
278
279impl ResourceProfileName {
280 pub fn as_str(&self) -> &str {
282 self.as_ref()
283 }
284}
285
286#[nutype(
287 sanitize(trim),
288 validate(with = validate_challenge_keyword, error = ChallengeKeywordError),
289 derive(
290 Debug,
291 Clone,
292 PartialEq,
293 Eq,
294 PartialOrd,
295 Ord,
296 Hash,
297 AsRef,
298 Deref,
299 Display,
300 Serialize,
301 Deserialize,
302 FromStr,
303 TryFrom,
304 ),
305)]
306pub struct ChallengeKeyword(String);
308
309impl ChallengeKeyword {
310 pub fn as_str(&self) -> &str {
312 self.as_ref()
313 }
314}
315
316#[nutype(
317 sanitize(trim, lowercase),
318 validate(
319 with = validate_moltbook_submolt_name,
320 error = MoltbookSubmoltNameError
321 ),
322 derive(
323 Debug,
324 Clone,
325 PartialEq,
326 Eq,
327 PartialOrd,
328 Ord,
329 Hash,
330 AsRef,
331 Deref,
332 Display,
333 Serialize,
334 Deserialize,
335 FromStr,
336 TryFrom,
337 ),
338)]
339pub struct MoltbookSubmoltName(String);
341
342impl MoltbookSubmoltName {
343 pub fn as_str(&self) -> &str {
345 self.as_ref()
346 }
347}
348
349#[nutype(
350 sanitize(trim),
351 validate(with = validate_metric_name, error = MetricNameError),
352 derive(
353 Debug,
354 Clone,
355 PartialEq,
356 Eq,
357 PartialOrd,
358 Ord,
359 Hash,
360 AsRef,
361 Deref,
362 Display,
363 Serialize,
364 Deserialize,
365 FromStr,
366 TryFrom,
367 ),
368)]
369pub struct MetricName(String);
371
372impl MetricName {
373 pub fn as_str(&self) -> &str {
375 self.as_ref()
376 }
377
378 #[allow(
380 clippy::panic,
381 reason = "the built-in `score` metric name is a hard-coded valid literal"
382 )]
383 pub fn score() -> Self {
385 match Self::try_new("score".to_string()) {
386 Ok(metric_name) => metric_name,
387 Err(_) => panic!("built-in metric name `score` must be valid"),
388 }
389 }
390}
391
392impl JsonSchema for ChallengeName {
393 fn inline_schema() -> bool {
395 true
396 }
397
398 fn schema_name() -> Cow<'static, str> {
400 "ChallengeName".into()
401 }
402
403 fn json_schema(_: &mut SchemaGenerator) -> Schema {
405 json_schema!({
406 "type": "string",
407 "minLength": 3,
408 "maxLength": 63,
409 "pattern": "^[a-z0-9](?:[a-z0-9]|-(?!-)){1,61}[a-z0-9]$"
410 })
411 }
412}
413
414macro_rules! impl_token_json_schema {
415 ($type_name:ident, $schema_name:literal) => {
416 impl JsonSchema for $type_name {
417 fn inline_schema() -> bool {
419 true
420 }
421
422 fn schema_name() -> Cow<'static, str> {
424 $schema_name.into()
425 }
426
427 fn json_schema(_: &mut SchemaGenerator) -> Schema {
429 json_schema!({
430 "type": "string",
431 "minLength": 1,
432 "pattern": "^[A-Za-z0-9_.-]+$"
433 })
434 }
435 }
436 };
437}
438
439impl_token_json_schema!(TargetName, "TargetName");
440impl_token_json_schema!(MetricName, "MetricName");
441impl_token_json_schema!(AssetName, "AssetName");
442impl_token_json_schema!(ResourceProfileName, "ResourceProfileName");
443
444impl JsonSchema for MoltbookSubmoltName {
445 fn inline_schema() -> bool {
447 true
448 }
449
450 fn schema_name() -> Cow<'static, str> {
452 "MoltbookSubmoltName".into()
453 }
454
455 fn json_schema(_: &mut SchemaGenerator) -> Schema {
457 json_schema!({
458 "type": "string",
459 "minLength": 2,
460 "maxLength": 30,
461 "pattern": "^[a-z0-9](?:[a-z0-9]|-(?!-)){0,28}[a-z0-9]$"
462 })
463 }
464}
465
466impl JsonSchema for RunName {
467 fn inline_schema() -> bool {
469 true
470 }
471
472 fn schema_name() -> Cow<'static, str> {
474 "RunName".into()
475 }
476
477 fn json_schema(_: &mut SchemaGenerator) -> Schema {
479 json_schema!({
480 "type": "string",
481 "minLength": 1,
482 "not": {
483 "enum": [".", ".."]
484 },
485 "pattern": "^[A-Za-z0-9_.-]+$"
486 })
487 }
488}
489
490impl JsonSchema for ChallengeKeyword {
491 fn inline_schema() -> bool {
493 true
494 }
495
496 fn schema_name() -> Cow<'static, str> {
498 "ChallengeKeyword".into()
499 }
500
501 fn json_schema(_: &mut SchemaGenerator) -> Schema {
503 json_schema!({
504 "type": "string",
505 "minLength": 1,
506 "maxLength": 30,
507 "description": "Public challenge keyword. Runtime validation enforces a 30 UTF-8 byte maximum and rejects control characters."
508 })
509 }
510}
511
512pub fn is_valid_challenge_name(value: &str) -> bool {
514 let bytes = value.as_bytes();
515 if !(3..=63).contains(&bytes.len()) {
516 return false;
517 }
518 let (Some(first), Some(last)) = (bytes.first(), bytes.last()) else {
519 return false;
520 };
521 if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
522 return false;
523 }
524 if value.contains("--") {
525 return false;
526 }
527 bytes
528 .iter()
529 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
530}
531
532fn has_name_token_syntax(value: &str) -> bool {
534 !value.is_empty()
535 && value
536 .bytes()
537 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
538}
539
540fn validate_challenge_name(value: &str) -> Result<(), ChallengeNameError> {
542 if is_valid_challenge_name(value) {
543 Ok(())
544 } else {
545 Err(ChallengeNameError)
546 }
547}
548
549fn validate_target_name(value: &str) -> Result<(), TargetNameError> {
551 if has_name_token_syntax(value) {
552 Ok(())
553 } else {
554 Err(TargetNameError)
555 }
556}
557
558fn validate_metric_name(value: &str) -> Result<(), MetricNameError> {
560 if has_name_token_syntax(value) {
561 Ok(())
562 } else {
563 Err(MetricNameError)
564 }
565}
566
567fn validate_asset_name(value: &str) -> Result<(), AssetNameError> {
569 if has_name_token_syntax(value) {
570 Ok(())
571 } else {
572 Err(AssetNameError)
573 }
574}
575
576fn validate_run_name(value: &str) -> Result<(), RunNameError> {
578 if has_name_token_syntax(value) && !matches!(value, "." | "..") {
579 Ok(())
580 } else {
581 Err(RunNameError)
582 }
583}
584
585fn validate_resource_profile_name(value: &str) -> Result<(), ResourceProfileNameError> {
587 if has_name_token_syntax(value) {
588 Ok(())
589 } else {
590 Err(ResourceProfileNameError)
591 }
592}
593
594fn validate_challenge_keyword(value: &str) -> Result<(), ChallengeKeywordError> {
596 if value.is_empty() || value.len() > 30 || value.chars().any(char::is_control) {
597 Err(ChallengeKeywordError)
598 } else {
599 Ok(())
600 }
601}
602
603fn validate_moltbook_submolt_name(value: &str) -> Result<(), MoltbookSubmoltNameError> {
605 let bytes = value.as_bytes();
606 if !(2..=30).contains(&bytes.len()) {
607 return Err(MoltbookSubmoltNameError);
608 }
609 let (Some(first), Some(last)) = (bytes.first(), bytes.last()) else {
610 return Err(MoltbookSubmoltNameError);
611 };
612 if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
613 return Err(MoltbookSubmoltNameError);
614 }
615 if value.contains("--")
616 || !bytes
617 .iter()
618 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
619 {
620 return Err(MoltbookSubmoltNameError);
621 }
622 Ok(())
623}
624
625#[cfg(test)]
626mod tests {
627 use super::{
628 AssetName, ChallengeKeyword, ChallengeName, MetricName, MoltbookSubmoltName,
629 ResourceProfileName, RunName, TargetName, is_valid_challenge_name,
630 };
631
632 #[test]
634 fn validates_challenge_names() {
635 assert!(is_valid_challenge_name("sample-sum"));
636 assert!(ChallengeName::try_new("matrix-multiplication").is_ok());
637 let canonical = ChallengeName::try_new(" Matrix-Multiplication ")
638 .expect("challenge names should be lowercased and trimmed");
639 assert_eq!(canonical.as_str(), "matrix-multiplication");
640 assert!(ChallengeName::try_new("Bad_ID").is_err());
641 assert!(ChallengeName::try_new("-bad").is_err());
642 assert!(ChallengeName::try_new("bad-").is_err());
643 assert!(ChallengeName::try_new("bad--id").is_err());
644 assert!(ChallengeName::try_new("ab").is_err());
645 assert!(ChallengeName::try_new("matrix mult").is_err());
646 }
647
648 #[test]
650 fn validates_token_names() {
651 for value in ["linux-arm64-cpu", "score.v1", "cuda_12"] {
652 assert!(TargetName::try_new(value).is_ok());
653 assert!(MetricName::try_new(value).is_ok());
654 assert!(AssetName::try_new(value).is_ok());
655 assert!(RunName::try_new(value).is_ok());
656 assert!(ResourceProfileName::try_new(value).is_ok());
657 }
658 for value in ["", "linux arm64", "linux/arm64", "bad\ntarget"] {
659 assert!(TargetName::try_new(value).is_err());
660 assert!(MetricName::try_new(value).is_err());
661 assert!(AssetName::try_new(value).is_err());
662 assert!(RunName::try_new(value).is_err());
663 assert!(ResourceProfileName::try_new(value).is_err());
664 }
665 for value in [".", ".."] {
666 assert!(TargetName::try_new(value).is_ok());
667 assert!(MetricName::try_new(value).is_ok());
668 assert!(AssetName::try_new(value).is_ok());
669 assert!(RunName::try_new(value).is_err());
670 assert!(ResourceProfileName::try_new(value).is_ok());
671 }
672 let metric = MetricName::try_new(" runtime_ms ").expect("metric names trim edge spaces");
673 assert_eq!(metric.as_str(), "runtime_ms");
674 assert!(MetricName::try_new("runtime ms").is_err());
675 }
676
677 #[test]
679 fn serde_rejects_invalid_names() {
680 let challenge: ChallengeName =
681 serde_json::from_str("\"sample-sum\"").expect("valid challenge name should parse");
682 assert_eq!(challenge.as_str(), "sample-sum");
683 let challenge: ChallengeName =
684 serde_json::from_str("\" Sample-Sum \"").expect("challenge name should canonicalize");
685 assert_eq!(challenge.as_str(), "sample-sum");
686 assert!(serde_json::from_str::<ChallengeName>("\"sample sum\"").is_err());
687
688 let target: TargetName =
689 serde_json::from_str("\"linux-arm64-cpu\"").expect("valid target should parse");
690 assert_eq!(target.as_str(), "linux-arm64-cpu");
691 assert!(serde_json::from_str::<TargetName>("\"linux arm64\"").is_err());
692
693 let metric: MetricName =
694 serde_json::from_str("\"runtime_ms\"").expect("valid metric name should parse");
695 assert_eq!(metric.as_str(), "runtime_ms");
696 let metric: MetricName =
697 serde_json::from_str("\" runtime_ms \"").expect("metric name should trim");
698 assert_eq!(metric.as_str(), "runtime_ms");
699 assert!(serde_json::from_str::<MetricName>("\"runtime ms\"").is_err());
700 }
701
702 #[test]
704 fn validates_challenge_keywords() {
705 let keyword = ChallengeKeyword::try_new(" protein folding ")
706 .expect("keyword phrases should trim edge whitespace");
707 assert_eq!(keyword.as_str(), "protein folding");
708 assert!(ChallengeKeyword::try_new("AI".to_string()).is_ok());
709 assert!(ChallengeKeyword::try_new("图搜索".to_string()).is_ok());
710 assert!(ChallengeKeyword::try_new("".to_string()).is_err());
711 assert!(ChallengeKeyword::try_new("bad\nkeyword".to_string()).is_err());
712 assert!(ChallengeKeyword::try_new("abcdefghijklmnopqrstuvwxyz12345".to_string()).is_err());
713 assert!(ChallengeKeyword::try_new("多字节多字节多字节多字节".to_string()).is_err());
714 }
715
716 #[test]
718 fn validates_moltbook_submolt_names() {
719 let name = MoltbookSubmoltName::try_new(" Agentics-Platform ".to_string())
720 .expect("submolt name should canonicalize");
721 assert_eq!(name.as_str(), "agentics-platform");
722 assert!(MoltbookSubmoltName::try_new("ai".to_string()).is_ok());
723 assert!(MoltbookSubmoltName::try_new("a".to_string()).is_err());
724 assert!(MoltbookSubmoltName::try_new("-agentics".to_string()).is_err());
725 assert!(MoltbookSubmoltName::try_new("agentics-".to_string()).is_err());
726 assert!(MoltbookSubmoltName::try_new("agentics--platform".to_string()).is_err());
727 assert!(MoltbookSubmoltName::try_new("agentics_platform".to_string()).is_err());
728 }
729}