1use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(default)]
22pub struct SchemaConfig {
23 #[serde(skip_serializing_if = "Option::is_none")]
26 pub owner: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub version: Option<u32>,
33
34 #[serde(default = "default_auto_migrate")]
40 pub auto_migrate: bool,
41
42 pub conversations: TableConfig,
43 pub responses: TableConfig,
44 pub conversation_items: TableConfig,
45 pub conversation_item_links: TableConfig,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
50#[serde(default)]
51pub struct TableConfig {
52 pub table: String,
54
55 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
58 pub columns: HashMap<String, String>,
59
60 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
64 pub extra_columns: HashMap<String, ColumnDef>,
65
66 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
70 pub skip_columns: HashSet<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct ColumnDef {
76 pub sql_type: String,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub default_value: Option<Value>,
83}
84
85fn default_auto_migrate() -> bool {
90 std::env::var("DB_AUTO_MIGRATE")
91 .ok()
92 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
93 .unwrap_or(false)
94}
95
96impl Default for SchemaConfig {
97 fn default() -> Self {
98 Self {
99 owner: None,
100 version: None,
101 auto_migrate: default_auto_migrate(),
102 conversations: TableConfig::with_table("conversations"),
103 responses: TableConfig::with_table("responses"),
104 conversation_items: TableConfig::with_table("conversation_items"),
105 conversation_item_links: TableConfig::with_table("conversation_item_links"),
106 }
107 }
108}
109
110impl TableConfig {
115 pub fn with_table(name: &str) -> Self {
117 Self {
118 table: name.to_string(),
119 ..Default::default()
120 }
121 }
122
123 pub fn col<'a>(&'a self, field: &'a str) -> &'a str {
128 self.columns.get(field).map(String::as_str).unwrap_or(field)
129 }
130
131 pub fn is_skipped(&self, field: &str) -> bool {
135 self.skip_columns.contains(field)
136 }
137
138 pub fn qualified_table(&self, owner: Option<&str>) -> String {
145 match owner {
146 Some(o) => format!("{o}.\"{}\"", self.table),
147 None => self.table.clone(),
148 }
149 }
150}
151
152impl SchemaConfig {
157 pub fn uppercase_for_oracle(&mut self) {
165 for tc in [
166 &mut self.conversations,
167 &mut self.responses,
168 &mut self.conversation_items,
169 &mut self.conversation_item_links,
170 ] {
171 tc.table.make_ascii_uppercase();
172 for val in tc.columns.values_mut() {
173 val.make_ascii_uppercase();
174 }
175 let keys: Vec<String> = tc.extra_columns.keys().cloned().collect();
178 for key in keys {
179 if let Some(def) = tc.extra_columns.remove(&key) {
180 tc.extra_columns.insert(key.to_ascii_uppercase(), def);
181 }
182 }
183 }
188 }
189
190 pub fn validate(&self) -> Result<(), String> {
192 if let Some(ref owner) = self.owner {
194 validate_identifier(owner).map_err(|e| format!("owner: {e}"))?;
195 }
196
197 Self::validate_table("conversations", &self.conversations)?;
199 Self::validate_table("responses", &self.responses)?;
200 Self::validate_table("conversation_items", &self.conversation_items)?;
201 Self::validate_table("conversation_item_links", &self.conversation_item_links)?;
202
203 Ok(())
204 }
205
206 fn validate_table(label: &str, tc: &TableConfig) -> Result<(), String> {
207 validate_identifier(&tc.table).map_err(|e| {
208 if tc.table.is_empty() {
209 format!("{label}.table: table name is required (got empty string — did you omit the 'table' key in your config?)")
210 } else {
211 format!("{label}.table: {e}")
212 }
213 })?;
214
215 for (logical, physical) in &tc.columns {
216 validate_identifier(logical)
217 .map_err(|e| format!("{label}.columns key '{logical}': {e}"))?;
218 validate_identifier(physical)
219 .map_err(|e| format!("{label}.columns value '{physical}': {e}"))?;
220 }
221
222 for (name, def) in &tc.extra_columns {
223 validate_identifier(name)
224 .map_err(|e| format!("{label}.extra_columns key '{name}': {e}"))?;
225 if def.sql_type.is_empty() {
226 return Err(format!(
227 "{label}.extra_columns['{name}']: sql_type must not be empty"
228 ));
229 }
230 validate_sql_type(&def.sql_type)
231 .map_err(|e| format!("{label}.extra_columns['{name}'].sql_type: {e}"))?;
232 }
233
234 let core = core_columns_for(label);
237 for name in tc.extra_columns.keys() {
238 let upper = name.to_ascii_uppercase();
239 if core.iter().any(|c| c.to_ascii_uppercase() == upper) {
240 return Err(format!(
241 "{label}.extra_columns: '{name}' shadows a core column name"
242 ));
243 }
244 }
245
246 let mut folded: HashSet<String> = HashSet::new();
249 for name in tc.extra_columns.keys() {
250 let upper = name.to_ascii_uppercase();
251 if !folded.insert(upper) {
252 return Err(format!(
253 "{label}.extra_columns: case-insensitive collision on '{name}' \
254 (Oracle normalizes identifiers to uppercase)"
255 ));
256 }
257 }
258
259 for name in &tc.skip_columns {
260 validate_identifier(name).map_err(|e| format!("{label}.skip_columns '{name}': {e}"))?;
261 if primary_key_columns_for(label).contains(&name.as_str()) {
262 return Err(format!(
263 "{label}.skip_columns: cannot skip '{name}' — it is part of the primary key"
264 ));
265 }
266 if !core.contains(&name.as_str()) {
267 return Err(format!(
268 "{label}.skip_columns: '{name}' is not a recognized column \
269 (known: {core:?})"
270 ));
271 }
272 }
273
274 Ok(())
275 }
276}
277
278fn primary_key_columns_for(_label: &str) -> &'static [&'static str] {
279 &["id"]
280}
281
282fn core_columns_for(label: &str) -> &'static [&'static str] {
285 match label {
286 "conversations" => &["id", "created_at", "metadata"],
287 "responses" => &[
288 "id",
289 "conversation_id",
290 "previous_response_id",
291 "input",
292 "created_at",
293 "safety_identifier",
294 "model",
295 "raw_response",
296 ],
297 "conversation_items" => &[
298 "id",
299 "response_id",
300 "item_type",
301 "role",
302 "content",
303 "status",
304 "created_at",
305 ],
306 "conversation_item_links" => &["conversation_id", "item_id", "added_at"],
307 _ => &[],
308 }
309}
310
311const MAX_IDENTIFIER_LEN: usize = 128;
314
315fn validate_identifier(name: &str) -> Result<(), String> {
317 if name.is_empty() {
318 return Err("identifier must not be empty".to_string());
319 }
320 if name.len() > MAX_IDENTIFIER_LEN {
321 return Err(format!(
322 "identifier '{name}' exceeds maximum length of {MAX_IDENTIFIER_LEN} characters"
323 ));
324 }
325 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
326 return Err(format!(
327 "invalid identifier '{name}' — only ASCII alphanumeric and underscores allowed"
328 ));
329 }
330 Ok(())
331}
332
333const MAX_SQL_TYPE_LEN: usize = 64;
335
336fn validate_sql_type(sql_type: &str) -> Result<(), String> {
342 if sql_type.trim().is_empty() {
343 return Err("sql_type must not be whitespace-only".to_string());
344 }
345 if sql_type.len() > MAX_SQL_TYPE_LEN {
346 return Err(format!(
347 "sql_type '{sql_type}' exceeds maximum length of {MAX_SQL_TYPE_LEN} characters"
348 ));
349 }
350 if !sql_type
351 .chars()
352 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ' ' | '(' | ')' | ',' | '.'))
353 {
354 return Err(format!(
355 "invalid sql_type '{sql_type}' — only ASCII alphanumeric, underscore, space, \
356 parentheses, comma, and period are allowed"
357 ));
358 }
359 Ok(())
360}
361
362#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
373 fn default_config_matches_hardcoded_names() {
374 let cfg = SchemaConfig::default();
375 assert_eq!(cfg.conversations.table, "conversations");
376 assert_eq!(cfg.responses.table, "responses");
377 assert_eq!(cfg.conversation_items.table, "conversation_items");
378 assert_eq!(cfg.conversation_item_links.table, "conversation_item_links");
379 assert!(cfg.owner.is_none());
380 }
381
382 #[test]
383 fn default_config_validates_successfully() {
384 SchemaConfig::default()
385 .validate()
386 .expect("default config should be valid");
387 }
388
389 #[test]
392 fn col_returns_field_name_when_no_override() {
393 let tc = TableConfig::with_table("t");
394 assert_eq!(tc.col("id"), "id");
395 assert_eq!(tc.col("created_at"), "created_at");
396 }
397
398 #[test]
399 fn col_returns_override_when_configured() {
400 let mut tc = TableConfig::with_table("t");
401 tc.columns
402 .insert("id".to_string(), "CONVERSATION_ID".to_string());
403 assert_eq!(tc.col("id"), "CONVERSATION_ID");
404 assert_eq!(tc.col("created_at"), "created_at");
406 }
407
408 #[test]
411 fn qualified_table_without_owner() {
412 let tc = TableConfig::with_table("conversations");
413 assert_eq!(tc.qualified_table(None), "conversations");
414 }
415
416 #[test]
417 fn qualified_table_with_owner() {
418 let tc = TableConfig::with_table("CONVERSATIONS");
419 assert_eq!(tc.qualified_table(Some("ADMIN")), "ADMIN.\"CONVERSATIONS\"");
420 }
421
422 #[test]
425 fn uppercase_for_oracle_converts_defaults() {
426 let mut cfg = SchemaConfig::default();
427 cfg.uppercase_for_oracle();
428 assert_eq!(cfg.conversations.table, "CONVERSATIONS");
429 assert_eq!(cfg.responses.table, "RESPONSES");
430 assert_eq!(cfg.conversation_items.table, "CONVERSATION_ITEMS");
431 assert_eq!(cfg.conversation_item_links.table, "CONVERSATION_ITEM_LINKS");
432 cfg.validate().expect("uppercased config should be valid");
433 }
434
435 #[test]
436 fn uppercase_for_oracle_converts_custom_table_and_columns() {
437 let mut cfg = SchemaConfig::default();
438 cfg.conversations.table = "my_convos".to_string();
439 cfg.conversations
440 .columns
441 .insert("id".to_string(), "conv_id".to_string());
442 cfg.uppercase_for_oracle();
443 assert_eq!(cfg.conversations.table, "MY_CONVOS");
444 assert_eq!(cfg.conversations.col("id"), "CONV_ID");
445 }
446
447 #[test]
450 fn validate_accepts_valid_identifiers() {
451 let mut cfg = SchemaConfig {
452 owner: Some("ADMIN_01".to_string()),
453 ..Default::default()
454 };
455 cfg.conversations.table = "MY_CONVERSATIONS".to_string();
456 cfg.conversations
457 .columns
458 .insert("id".to_string(), "CONV_ID".to_string());
459 cfg.validate().expect("should be valid");
460 }
461
462 #[test]
463 fn validate_rejects_empty_table_name_with_helpful_message() {
464 let mut cfg = SchemaConfig::default();
465 cfg.conversations.table = String::new();
466 let err = cfg.validate().unwrap_err();
467 assert!(
468 err.contains("conversations.table") && err.contains("table name is required"),
469 "unexpected: {err}"
470 );
471 }
472
473 #[test]
474 fn validate_rejects_overly_long_identifier() {
475 let mut cfg = SchemaConfig::default();
476 cfg.conversations.table = "a".repeat(129);
477 let err = cfg.validate().unwrap_err();
478 assert!(err.contains("exceeds maximum length"), "unexpected: {err}");
479 }
480
481 #[test]
482 fn validate_rejects_special_characters() {
483 let mut cfg = SchemaConfig::default();
484 cfg.conversations.table = "table;DROP".to_string();
485 let err = cfg.validate().unwrap_err();
486 assert!(
487 err.contains("conversations.table") && err.contains("invalid identifier"),
488 "unexpected: {err}"
489 );
490 }
491
492 #[test]
493 fn validate_rejects_dots_in_owner() {
494 let cfg = SchemaConfig {
495 owner: Some("ADMIN.SCHEMA".to_string()),
496 ..Default::default()
497 };
498 let err = cfg.validate().unwrap_err();
499 assert!(
500 err.contains("owner") && err.contains("invalid identifier"),
501 "unexpected: {err}"
502 );
503 }
504
505 #[test]
506 fn validate_rejects_invalid_column_override_key() {
507 let mut cfg = SchemaConfig::default();
508 cfg.responses
509 .columns
510 .insert("bad;col".to_string(), "GOOD_COL".to_string());
511 let err = cfg.validate().unwrap_err();
512 assert!(
513 err.contains("columns key") && err.contains("invalid identifier"),
514 "unexpected: {err}"
515 );
516 }
517
518 #[test]
519 fn validate_rejects_invalid_column_override_value() {
520 let mut cfg = SchemaConfig::default();
521 cfg.responses
522 .columns
523 .insert("id".to_string(), "bad column!".to_string());
524 let err = cfg.validate().unwrap_err();
525 assert!(
526 err.contains("columns value") && err.contains("invalid identifier"),
527 "unexpected: {err}"
528 );
529 }
530
531 #[test]
534 fn serde_roundtrip_default() {
535 let cfg = SchemaConfig::default();
536 let json = serde_json::to_string(&cfg).expect("serialize");
537 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
538 assert_eq!(cfg, restored);
539 }
540
541 #[test]
542 fn serde_roundtrip_custom() {
543 let mut cfg = SchemaConfig {
544 owner: Some("ADMIN".to_string()),
545 ..Default::default()
546 };
547 cfg.conversations.table = "CONVERSATIONS".to_string();
548 cfg.conversations
549 .columns
550 .insert("id".to_string(), "CONVERSATION_ID".to_string());
551
552 let json = serde_json::to_string(&cfg).expect("serialize");
553 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
554 assert_eq!(cfg, restored);
555 }
556
557 #[test]
558 fn serde_deserialize_empty_object_uses_defaults() {
559 let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
560 assert_eq!(cfg, SchemaConfig::default());
561 }
562
563 #[test]
566 fn serde_roundtrip_with_version_and_auto_migrate() {
567 let cfg = SchemaConfig {
568 version: Some(3),
569 auto_migrate: false,
570 ..Default::default()
571 };
572 let json = serde_json::to_string(&cfg).expect("serialize");
573 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
574 assert_eq!(restored.version, Some(3));
575 assert!(!restored.auto_migrate);
576 }
577
578 #[test]
579 fn serde_defaults_version_none_and_auto_migrate_false() {
580 let cfg: SchemaConfig = serde_json::from_str("{}").expect("deserialize empty");
581 assert_eq!(cfg.version, None);
582 assert!(!cfg.auto_migrate);
583 }
584
585 #[test]
586 fn serde_version_none_is_omitted_from_json() {
587 let cfg = SchemaConfig::default();
588 let json = serde_json::to_string(&cfg).expect("serialize");
589 assert!(
590 !json.contains("version"),
591 "version:None should be skipped: {json}"
592 );
593 }
594
595 #[test]
598 fn is_skipped_returns_false_by_default() {
599 let tc = TableConfig::with_table("t");
600 assert!(!tc.is_skipped("id"));
601 assert!(!tc.is_skipped("safety_identifier"));
602 }
603
604 #[test]
605 fn is_skipped_returns_true_for_configured_fields() {
606 let mut tc = TableConfig::with_table("t");
607 tc.skip_columns.insert("safety_identifier".to_string());
608 tc.skip_columns.insert("raw_response".to_string());
609 assert!(tc.is_skipped("safety_identifier"));
610 assert!(tc.is_skipped("raw_response"));
611 assert!(!tc.is_skipped("id"));
612 }
613
614 #[test]
617 fn extra_columns_default_is_empty() {
618 let tc = TableConfig::with_table("t");
619 assert!(tc.extra_columns.is_empty());
620 }
621
622 #[test]
623 fn validate_accepts_valid_extra_columns() {
624 let mut cfg = SchemaConfig::default();
625 cfg.conversations.extra_columns.insert(
626 "EXPIRES_AT".to_string(),
627 ColumnDef {
628 sql_type: "TIMESTAMP".to_string(),
629 default_value: None,
630 },
631 );
632 cfg.validate().expect("should be valid");
633 }
634
635 #[test]
636 fn validate_rejects_invalid_extra_column_name() {
637 let mut cfg = SchemaConfig::default();
638 cfg.conversations.extra_columns.insert(
639 "bad;col".to_string(),
640 ColumnDef {
641 sql_type: "TEXT".to_string(),
642 default_value: None,
643 },
644 );
645 let err = cfg.validate().unwrap_err();
646 assert!(
647 err.contains("extra_columns key") && err.contains("invalid identifier"),
648 "unexpected: {err}"
649 );
650 }
651
652 #[test]
653 fn validate_rejects_empty_sql_type() {
654 let mut cfg = SchemaConfig::default();
655 cfg.conversations.extra_columns.insert(
656 "MY_COL".to_string(),
657 ColumnDef {
658 sql_type: String::new(),
659 default_value: None,
660 },
661 );
662 let err = cfg.validate().unwrap_err();
663 assert!(
664 err.contains("sql_type must not be empty"),
665 "unexpected: {err}"
666 );
667 }
668
669 #[test]
670 fn validate_rejects_invalid_skip_column_name() {
671 let mut cfg = SchemaConfig::default();
672 cfg.responses.skip_columns.insert("bad;col".to_string());
673 let err = cfg.validate().unwrap_err();
674 assert!(
675 err.contains("skip_columns") && err.contains("invalid identifier"),
676 "unexpected: {err}"
677 );
678 }
679
680 #[test]
681 fn validate_accepts_valid_skip_columns() {
682 let mut cfg = SchemaConfig::default();
683 cfg.responses
684 .skip_columns
685 .insert("safety_identifier".to_string());
686 cfg.validate().expect("should be valid");
687 }
688
689 #[test]
690 fn validate_rejects_skip_id() {
691 let mut cfg = SchemaConfig::default();
692 cfg.responses.skip_columns.insert("id".to_string());
693 let err = cfg.validate().unwrap_err();
694 assert!(
695 err.contains("cannot skip 'id'") && err.contains("primary key"),
696 "unexpected: {err}"
697 );
698 }
699
700 #[test]
703 fn validate_accepts_valid_sql_types() {
704 let mut cfg = SchemaConfig::default();
705 for sql_type in [
706 "VARCHAR(128)",
707 "TEXT",
708 "TIMESTAMP",
709 "TIMESTAMP WITH TIME ZONE",
710 "NUMBER(10,2)",
711 "VARCHAR2(256)",
712 "BIGINT",
713 ] {
714 cfg.conversations.extra_columns.insert(
715 "TEST_COL".to_string(),
716 ColumnDef {
717 sql_type: sql_type.to_string(),
718 default_value: None,
719 },
720 );
721 cfg.validate()
722 .unwrap_or_else(|e| panic!("sql_type '{sql_type}' should be valid: {e}"));
723 }
724 }
725
726 #[test]
727 fn validate_rejects_sql_injection_in_sql_type() {
728 let mut cfg = SchemaConfig::default();
729 cfg.conversations.extra_columns.insert(
730 "MY_COL".to_string(),
731 ColumnDef {
732 sql_type: "TEXT); DROP TABLE responses; --".to_string(),
733 default_value: None,
734 },
735 );
736 let err = cfg.validate().unwrap_err();
737 assert!(
738 err.contains("sql_type") && err.contains("invalid"),
739 "unexpected: {err}"
740 );
741 }
742
743 #[test]
744 fn validate_rejects_overly_long_sql_type() {
745 let mut cfg = SchemaConfig::default();
746 cfg.conversations.extra_columns.insert(
747 "MY_COL".to_string(),
748 ColumnDef {
749 sql_type: "A".repeat(65),
750 default_value: None,
751 },
752 );
753 let err = cfg.validate().unwrap_err();
754 assert!(
755 err.contains("sql_type") && err.contains("exceeds maximum length"),
756 "unexpected: {err}"
757 );
758 }
759
760 #[test]
763 fn uppercase_for_oracle_converts_extra_column_keys() {
764 let mut cfg = SchemaConfig::default();
765 cfg.conversations.extra_columns.insert(
766 "expires_at".to_string(),
767 ColumnDef {
768 sql_type: "TIMESTAMP".to_string(),
769 default_value: None,
770 },
771 );
772 cfg.uppercase_for_oracle();
773 assert!(cfg.conversations.extra_columns.contains_key("EXPIRES_AT"));
774 assert!(!cfg.conversations.extra_columns.contains_key("expires_at"));
775 }
776
777 #[test]
778 fn uppercase_for_oracle_preserves_skip_column_names_lowercase() {
779 let mut cfg = SchemaConfig::default();
780 cfg.responses
781 .skip_columns
782 .insert("safety_identifier".to_string());
783 cfg.uppercase_for_oracle();
784 assert!(cfg.responses.skip_columns.contains("safety_identifier"));
786 assert!(!cfg.responses.skip_columns.contains("SAFETY_IDENTIFIER"));
787 }
788
789 #[test]
792 fn serde_roundtrip_with_extra_and_skip() {
793 let mut cfg = SchemaConfig::default();
794 cfg.conversations.extra_columns.insert(
795 "EXPIRES_AT".to_string(),
796 ColumnDef {
797 sql_type: "TIMESTAMP".to_string(),
798 default_value: Some(Value::String("2099-01-01".to_string())),
799 },
800 );
801 cfg.responses
802 .skip_columns
803 .insert("safety_identifier".to_string());
804
805 let json = serde_json::to_string(&cfg).expect("serialize");
806 let restored: SchemaConfig = serde_json::from_str(&json).expect("deserialize");
807 assert_eq!(cfg, restored);
808 }
809
810 #[test]
811 fn validate_rejects_extra_column_shadowing_core_column() {
812 let mut cfg = SchemaConfig::default();
813 cfg.responses.extra_columns.insert(
814 "CREATED_AT".to_string(),
815 ColumnDef {
816 sql_type: "TIMESTAMP".to_string(),
817 default_value: None,
818 },
819 );
820 let err = cfg.validate().unwrap_err();
821 assert!(err.contains("shadows a core column"), "unexpected: {err}");
822
823 let mut cfg2 = SchemaConfig::default();
825 cfg2.conversations.extra_columns.insert(
826 "metadata".to_string(),
827 ColumnDef {
828 sql_type: "TEXT".to_string(),
829 default_value: None,
830 },
831 );
832 let err2 = cfg2.validate().unwrap_err();
833 assert!(err2.contains("shadows a core column"), "unexpected: {err2}");
834 }
835
836 #[test]
837 fn validate_rejects_unknown_skip_column() {
838 let mut cfg = SchemaConfig::default();
839 cfg.responses
840 .skip_columns
841 .insert("safty_identifier".to_string()); let err = cfg.validate().unwrap_err();
843 assert!(err.contains("not a recognized column"), "unexpected: {err}");
844 }
845
846 #[test]
847 fn validate_rejects_case_colliding_extra_columns() {
848 let mut cfg = SchemaConfig::default();
849 cfg.conversations.extra_columns.insert(
850 "tenant_id".to_string(),
851 ColumnDef {
852 sql_type: "TEXT".to_string(),
853 default_value: None,
854 },
855 );
856 cfg.conversations.extra_columns.insert(
857 "TENANT_ID".to_string(),
858 ColumnDef {
859 sql_type: "TEXT".to_string(),
860 default_value: None,
861 },
862 );
863 let err = cfg.validate().unwrap_err();
864 assert!(
865 err.contains("case-insensitive collision"),
866 "unexpected: {err}"
867 );
868 }
869}