1use chrono::DateTime;
4use unicode_normalization::UnicodeNormalization;
5
6pub fn parse_expected_updated_at(s: &str) -> Result<i64, String> {
8 if let Ok(secs) = s.parse::<i64>() {
9 if secs >= 0 {
10 return Ok(secs);
11 }
12 }
13 DateTime::parse_from_rfc3339(s)
14 .map(|dt| dt.timestamp())
15 .map_err(|e| {
16 format!(
17 "value must be a Unix epoch (integer >= 0) or RFC 3339 (e.g. 2026-04-19T12:00:00Z): {e}"
18 )
19 })
20}
21
22fn parse_usize_in_range(s: &str, lo: usize, hi: usize) -> Result<usize, String> {
32 let value: usize = s
33 .parse()
34 .map_err(|_| format!("'{s}' is not a valid non-negative integer"))?;
35 if !(lo..=hi).contains(&value) {
36 return Err(format!(
40 "must be between {lo} and {hi} (inclusive); got {value}"
41 ));
42 }
43 Ok(value)
44}
45
46pub fn parse_k_range(s: &str) -> Result<usize, String> {
53 parse_usize_in_range(s, 1, crate::constants::K_QUERY_RANGE_MAX)
54}
55
56pub fn parse_list_limit_range(s: &str) -> Result<usize, String> {
63 parse_usize_in_range(s, 1, crate::constants::K_LIST_LIMIT_MAX)
64}
65
66pub fn parse_hops_range_usize(s: &str) -> Result<usize, String> {
68 parse_usize_in_range(s, 1, crate::constants::K_MAX_HOPS_CEILING as usize)
69}
70
71pub fn parse_hops_range_u32(s: &str) -> Result<u32, String> {
73 parse_usize_in_range(s, 1, crate::constants::K_MAX_HOPS_CEILING as usize).map(|v| v as u32)
74}
75
76pub fn parse_quality_sample_range(s: &str) -> Result<usize, String> {
90 parse_usize_in_range(s, 0, crate::constants::K_QUERY_RANGE_MAX)
91}
92
93pub fn parse_sub_queries_range(s: &str) -> Result<usize, String> {
98 parse_usize_in_range(s, 1, crate::constants::K_MAX_SUB_QUERIES_CEILING)
99}
100
101pub fn parse_bool_flexible(s: &str) -> Result<bool, String> {
107 match s.to_lowercase().as_str() {
108 "1" | "true" | "yes" | "on" => Ok(true),
109 "0" | "false" | "no" | "off" | "" => Ok(false),
110 _ => Err(format!(
111 "invalid boolean value '{s}': expected true/false/1/0/yes/no/on/off"
112 )),
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn accepts_unix_epoch() {
122 assert_eq!(parse_expected_updated_at("1700000000").unwrap(), 1700000000);
123 }
124
125 #[test]
126 fn accepts_zero() {
127 assert_eq!(parse_expected_updated_at("0").unwrap(), 0);
128 }
129
130 #[test]
131 fn accepts_rfc_3339_utc() {
132 let result = parse_expected_updated_at("2020-01-01T00:00:00Z");
133 assert!(result.is_ok());
134 assert_eq!(result.unwrap(), 1577836800);
135 }
136
137 #[test]
138 fn accepts_rfc_3339_with_offset() {
139 let result = parse_expected_updated_at("2026-04-19T12:00:00+00:00");
140 assert!(result.is_ok());
141 }
142
143 #[test]
144 fn rejects_invalid_string() {
145 assert!(parse_expected_updated_at("bananas").is_err());
146 }
147
148 #[test]
149 fn rejects_negative() {
150 let err = parse_expected_updated_at("-1");
151 assert!(err.is_err());
152 }
153
154 #[test]
155 fn error_message_mentions_format() {
156 let msg = parse_expected_updated_at("invalid").unwrap_err();
157 assert!(msg.contains("RFC 3339") || msg.contains("Unix epoch"));
158 }
159
160 #[test]
161 fn k_accepts_valid_range_endpoints() {
162 assert_eq!(parse_k_range("1").unwrap(), 1);
163 assert_eq!(parse_k_range("4096").unwrap(), 4096);
164 assert_eq!(parse_k_range("10").unwrap(), 10);
165 }
166
167 #[test]
168 fn k_rejects_zero() {
169 let msg = parse_k_range("0").unwrap_err();
170 assert!(msg.contains("between 1 and 4096"));
171 }
172
173 #[test]
174 fn k_rejects_above_limit() {
175 let msg = parse_k_range("10000").unwrap_err();
176 assert!(msg.contains("between 1 and 4096"));
177 }
178
179 #[test]
180 fn k_rejects_non_integer() {
181 let msg = parse_k_range("abc").unwrap_err();
182 assert!(msg.contains("not a valid"));
183 }
184
185 #[test]
186 fn k_rejects_negative() {
187 assert!(parse_k_range("-5").is_err());
189 }
190
191 #[test]
192 fn bool_flexible_truthy() {
193 for v in &["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] {
194 assert!(parse_bool_flexible(v).unwrap(), "should be true: {v}");
195 }
196 }
197
198 #[test]
199 fn bool_flexible_falsy() {
200 for v in &["0", "false", "False", "FALSE", "no", "No", "off", "OFF", ""] {
201 assert!(!parse_bool_flexible(v).unwrap(), "should be false: {v}");
202 }
203 }
204
205 #[test]
206 fn bool_flexible_rejects_invalid() {
207 assert!(parse_bool_flexible("banana").is_err());
208 assert!(parse_bool_flexible("2").is_err());
209 assert!(parse_bool_flexible("nope").is_err());
210 }
211}
212
213pub const CANONICAL_RELATIONS: &[&str] = &[
234 "applies-to",
235 "uses",
236 "depends-on",
237 "causes",
238 "fixes",
239 "contradicts",
240 "supports",
241 "follows",
242 "related",
243 "mentions",
244 "replaces",
245 "tracked-in",
246];
247
248pub const GENERIC_RELATION: &str = "applies-to";
254
255pub fn is_canonical_relation(s: &str) -> bool {
257 CANONICAL_RELATIONS.contains(&s)
258}
259
260pub fn normalize_relation(s: &str) -> String {
265 s.to_lowercase().replace('_', "-")
266}
267
268pub fn normalize_entity_name(s: &str) -> String {
285 let ascii: String = s.nfkd().filter(|c| c.is_ascii()).collect();
288 let hyphenated: String = ascii
290 .to_lowercase()
291 .chars()
292 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
293 .collect();
294 let mut result = String::with_capacity(hyphenated.len());
296 let mut prev_was_hyphen = false;
297 for ch in hyphenated.chars() {
298 if ch == '-' {
299 if !prev_was_hyphen {
300 result.push('-');
301 }
302 prev_was_hyphen = true;
303 } else {
304 result.push(ch);
305 prev_was_hyphen = false;
306 }
307 }
308 result.trim_matches('-').to_string()
309}
310
311pub fn validate_relation_format(s: &str) -> Result<(), String> {
322 if s.is_empty() {
323 return Err("relation must not be empty".to_string());
324 }
325 if !s.as_bytes()[0].is_ascii_lowercase() {
326 return Err(format!(
327 "relation must start with a lowercase letter, got '{s}'"
328 ));
329 }
330 if !s
331 .bytes()
332 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
333 {
334 return Err(format!(
335 "relation must contain only lowercase letters, digits and hyphens, got '{s}'"
336 ));
337 }
338 Ok(())
339}
340
341pub fn map_to_canonical_relation(s: &str) -> String {
358 let normalized = normalize_relation(s);
359 if is_canonical_relation(&normalized) {
360 return normalized;
361 }
362 match normalized.as_str() {
363 "adds" | "creates" => "causes",
364 "implements" => "supports",
365 "blocks" => "contradicts",
366 "tested-by" | "related-to" => "related",
367 "part-of" => "applies-to",
368 _ => "related",
371 }
372 .to_string()
373}
374
375pub fn warn_if_non_canonical(relation: &str) {
377 if !is_canonical_relation(relation) {
378 tracing::warn!(target: "parsers",
379 relation,
380 "non-canonical relation accepted; consider using a well-known value"
381 );
382 }
383}
384
385pub fn parse_relation(s: &str) -> Result<String, String> {
390 let normalized = normalize_relation(s);
391 validate_relation_format(&normalized)?;
392 Ok(normalized)
393}
394
395#[cfg(test)]
396mod relation_tests {
397 use super::*;
398
399 #[test]
400 fn canonical_relations_all_valid() {
401 for r in CANONICAL_RELATIONS {
402 assert!(
403 validate_relation_format(r).is_ok(),
404 "canonical relation '{r}' should be valid"
405 );
406 }
407 }
408
409 #[test]
415 fn normalize_converts_underscores_and_uppercase() {
416 assert_eq!(normalize_relation("Depends_On"), "depends-on");
417 assert_eq!(normalize_relation("TESTED_BY"), "tested-by");
418 assert_eq!(normalize_relation("uses"), "uses");
419 }
420
421 #[test]
422 fn validate_rejects_empty() {
423 assert!(validate_relation_format("").is_err());
424 }
425
426 #[test]
427 fn validate_rejects_digit_start() {
428 assert!(validate_relation_format("123abc").is_err());
429 }
430
431 #[test]
432 fn validate_rejects_spaces() {
433 assert!(validate_relation_format("has spaces").is_err());
434 }
435
436 #[test]
437 fn validate_accepts_custom_relations() {
438 assert!(validate_relation_format("implements").is_ok());
440 assert!(validate_relation_format("tested-by").is_ok());
441 assert!(validate_relation_format("part-of").is_ok());
442 assert!(validate_relation_format("blocks").is_ok());
443 }
444
445 #[test]
452 fn normaliser_output_always_passes_the_validator() {
453 for raw in [
454 "Applies-To",
455 "applies_to",
456 "DEPENDS_ON",
457 "depends-on",
458 "tracked_in",
459 "uses",
460 "tested-by",
461 "part_of",
462 ] {
463 let normalized = normalize_relation(raw);
464 assert!(
465 validate_relation_format(&normalized).is_ok(),
466 "normalize_relation({raw:?}) produced {normalized:?}, which the validator rejects — the two disagree about the stored form"
467 );
468 }
469 for rel in CANONICAL_RELATIONS {
470 assert_eq!(
471 &normalize_relation(rel),
472 rel,
473 "normalize_relation is not idempotent on the canonical relation {rel:?}, so the constant names a form the crate never stores"
474 );
475 assert!(validate_relation_format(rel).is_ok());
476 }
477 }
478
479 #[test]
480 fn parse_relation_normalizes_and_validates() {
481 assert_eq!(parse_relation("Tested_By").unwrap(), "tested-by");
482 assert_eq!(parse_relation("Tested-By").unwrap(), "tested-by");
483 assert_eq!(parse_relation("uses").unwrap(), "uses");
484 assert!(parse_relation("").is_err());
485 }
486
487 #[test]
488 fn is_canonical_detects_known() {
489 assert!(is_canonical_relation("uses"));
490 assert!(is_canonical_relation("applies-to"));
491 assert!(!is_canonical_relation("applies_to"));
495 assert!(!is_canonical_relation("implements"));
496 assert!(!is_canonical_relation("blocks"));
497 }
498
499 #[test]
500 fn map_to_canonical_relation_passes_through_canonical() {
501 assert_eq!(map_to_canonical_relation("uses"), "uses");
502 assert_eq!(map_to_canonical_relation("Applies-To"), "applies-to");
503 assert_eq!(map_to_canonical_relation("DEPENDS_ON"), "depends-on");
504 assert_eq!(map_to_canonical_relation("applies_to"), "applies-to");
507 assert_eq!(map_to_canonical_relation("tracked_in"), "tracked-in");
508 }
509
510 #[test]
511 fn map_to_canonical_relation_rewrites_known_aliases() {
512 assert_eq!(map_to_canonical_relation("part-of"), "applies-to");
514 assert_eq!(map_to_canonical_relation("part_of"), "applies-to");
515 assert_eq!(map_to_canonical_relation("implements"), "supports");
516 assert_eq!(map_to_canonical_relation("blocks"), "contradicts");
517 assert_eq!(map_to_canonical_relation("adds"), "causes");
518 assert_eq!(map_to_canonical_relation("creates"), "causes");
519 assert_eq!(map_to_canonical_relation("tested-by"), "related");
520 assert_eq!(map_to_canonical_relation("related_to"), "related");
521 assert_eq!(map_to_canonical_relation("related-to"), "related");
522 }
523
524 #[test]
525 fn map_to_canonical_relation_unknown_folds_to_related() {
526 assert_eq!(map_to_canonical_relation("some-weird-relation"), "related");
527 assert!(is_canonical_relation(&map_to_canonical_relation("xyz")));
529 }
530}
531
532#[cfg(test)]
533mod entity_name_tests {
534 use super::*;
535
536 #[test]
537 fn strips_diacritics_from_accented_name() {
538 assert_eq!(normalize_entity_name("Alice Martins"), "alice-martins");
539 }
540
541 #[test]
542 fn strips_diacritics_unicode_accents() {
543 assert_eq!(normalize_entity_name("São Paulo"), "sao-paulo");
545 assert_eq!(normalize_entity_name("Ünit Tëst"), "unit-test");
546 }
547
548 #[test]
549 fn converts_spaces_to_hyphens() {
550 assert_eq!(normalize_entity_name("hello world"), "hello-world");
551 assert_eq!(normalize_entity_name(" hello world "), "hello-world");
552 }
553
554 #[test]
555 fn converts_underscores_to_hyphens() {
556 assert_eq!(normalize_entity_name("hello_world"), "hello-world");
557 assert_eq!(
558 normalize_entity_name("CANONICAL_RELATIONS"),
559 "canonical-relations"
560 );
561 }
562
563 #[test]
564 fn all_caps_becomes_lowercase_kebab() {
565 assert_eq!(
566 normalize_entity_name("CANONICAL_RELATIONS"),
567 "canonical-relations"
568 );
569 assert_eq!(normalize_entity_name("MY_ENTITY_NAME"), "my-entity-name");
570 }
571
572 #[test]
573 fn idempotent_on_already_normalized() {
574 let name = "alice-martins";
575 assert_eq!(normalize_entity_name(name), name);
576 let name2 = "canonical-relations";
577 assert_eq!(normalize_entity_name(name2), name2);
578 }
579
580 #[test]
581 fn collapses_consecutive_hyphens() {
582 assert_eq!(normalize_entity_name("foo--bar"), "foo-bar");
583 assert_eq!(normalize_entity_name("foo - bar"), "foo-bar");
584 }
585
586 #[test]
587 fn trims_leading_trailing_hyphens() {
588 assert_eq!(normalize_entity_name("-foo-"), "foo");
589 assert_eq!(normalize_entity_name("--hello--"), "hello");
590 }
591
592 #[test]
593 fn empty_or_only_separators_returns_empty() {
594 assert_eq!(normalize_entity_name(""), "");
595 assert_eq!(normalize_entity_name("---"), "");
596 }
597
598 #[test]
599 fn normalizes_dots_slashes_and_punctuation() {
600 assert_eq!(normalize_entity_name("lei-14.478/2022"), "lei-14-478-2022");
601 assert_eq!(normalize_entity_name("src/main.rs"), "src-main-rs");
602 assert_eq!(normalize_entity_name("user@domain.com"), "user-domain-com");
603 assert_eq!(normalize_entity_name("v1.0.66"), "v1-0-66");
604 assert_eq!(normalize_entity_name("key:value"), "key-value");
605 }
606}