1pub mod project;
4
5use anyhow::Result;
6use parse_display::{Display, FromStr};
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, Serialize};
9use validator::{Validate, ValidateRange};
10
11const DEFAULT_THEME_COLOR: f64 = 264.5;
12const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
13
14#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
20#[ts(export)]
21#[serde(rename_all = "snake_case")]
22pub struct Configuration {
23 #[serde(default, skip_serializing_if = "is_default")]
25 #[validate(nested)]
26 pub settings: Settings,
27}
28
29impl Configuration {
30 pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
31 let settings = toml::from_str::<Self>(toml_str)?;
32
33 settings.validate()?;
34
35 Ok(settings)
36 }
37}
38
39#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
41#[ts(export)]
42#[serde(rename_all = "snake_case")]
43pub struct Settings {
44 #[serde(default, skip_serializing_if = "is_default")]
46 #[validate(nested)]
47 pub app: AppSettings,
48 #[serde(default, skip_serializing_if = "is_default")]
50 #[validate(nested)]
51 pub modeling: ModelingSettings,
52 #[serde(default, skip_serializing_if = "is_default")]
54 #[validate(nested)]
55 pub text_editor: TextEditorSettings,
56 #[serde(default, skip_serializing_if = "is_default")]
58 #[validate(nested)]
59 pub project: ProjectSettings,
60 #[serde(default, skip_serializing_if = "is_default")]
62 #[validate(nested)]
63 pub command_bar: CommandBarSettings,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
68#[ts(export)]
69#[serde(rename_all = "snake_case")]
70pub struct AppSettings {
71 #[serde(default, skip_serializing_if = "is_default")]
73 #[validate(nested)]
74 pub appearance: AppearanceSettings,
75 #[serde(default, skip_serializing_if = "is_default")]
77 pub onboarding_status: OnboardingStatus,
78 #[serde(default, skip_serializing_if = "is_default")]
81 pub dismiss_web_banner: bool,
82 #[serde(
84 default,
85 deserialize_with = "deserialize_stream_idle_mode",
86 alias = "streamIdleMode",
87 skip_serializing_if = "is_default"
88 )]
89 stream_idle_mode: Option<u32>,
90 #[serde(default, skip_serializing_if = "is_default")]
92 pub allow_orbit_in_sketch_mode: bool,
93 #[serde(default, skip_serializing_if = "is_default")]
96 pub show_debug_panel: bool,
97 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
100 pub fixed_size_grid: bool,
101}
102
103fn make_it_so() -> bool {
105 true
106}
107
108fn is_true(b: &bool) -> bool {
109 *b
110}
111
112impl Default for AppSettings {
113 fn default() -> Self {
114 Self {
115 appearance: Default::default(),
116 onboarding_status: Default::default(),
117 dismiss_web_banner: Default::default(),
118 stream_idle_mode: Default::default(),
119 allow_orbit_in_sketch_mode: Default::default(),
120 show_debug_panel: Default::default(),
121 fixed_size_grid: make_it_so(),
122 }
123 }
124}
125
126fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
127where
128 D: Deserializer<'de>,
129{
130 #[derive(Deserialize)]
131 #[serde(untagged)]
132 enum StreamIdleModeValue {
133 Number(u32),
134 String(String),
135 Boolean(bool),
136 }
137
138 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
139
140 Ok(match StreamIdleModeValue::deserialize(deserializer) {
141 Ok(StreamIdleModeValue::Number(value)) => Some(value),
142 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
143 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
146 Ok(StreamIdleModeValue::Boolean(false)) => None,
147 _ => None,
148 })
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
152#[ts(export)]
153#[serde(untagged)]
154pub enum FloatOrInt {
155 String(String),
156 Float(f64),
157 Int(i64),
158}
159
160impl From<FloatOrInt> for f64 {
161 fn from(float_or_int: FloatOrInt) -> Self {
162 match float_or_int {
163 FloatOrInt::String(s) => s.parse().unwrap(),
164 FloatOrInt::Float(f) => f,
165 FloatOrInt::Int(i) => i as f64,
166 }
167 }
168}
169
170impl From<FloatOrInt> for AppColor {
171 fn from(float_or_int: FloatOrInt) -> Self {
172 match float_or_int {
173 FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
174 FloatOrInt::Float(f) => f.into(),
175 FloatOrInt::Int(i) => (i as f64).into(),
176 }
177 }
178}
179
180#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
182#[ts(export)]
183#[serde(rename_all = "snake_case")]
184pub struct AppearanceSettings {
185 #[serde(default, skip_serializing_if = "is_default")]
187 pub theme: AppTheme,
188 #[serde(default, skip_serializing_if = "is_default")]
190 #[validate(nested)]
191 pub color: AppColor,
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
195#[ts(export)]
196#[serde(transparent)]
197pub struct AppColor(pub f64);
198
199impl Default for AppColor {
200 fn default() -> Self {
201 Self(DEFAULT_THEME_COLOR)
202 }
203}
204
205impl From<AppColor> for f64 {
206 fn from(color: AppColor) -> Self {
207 color.0
208 }
209}
210
211impl From<f64> for AppColor {
212 fn from(color: f64) -> Self {
213 Self(color)
214 }
215}
216
217impl Validate for AppColor {
218 fn validate(&self) -> Result<(), validator::ValidationErrors> {
219 if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
220 let mut errors = validator::ValidationErrors::new();
221 let mut err = validator::ValidationError::new("color");
222 err.add_param(std::borrow::Cow::from("min"), &0.0);
223 err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
224 errors.add("color", err);
225 return Err(errors);
226 }
227 Ok(())
228 }
229}
230
231#[derive(
233 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
234)]
235#[ts(export)]
236#[serde(rename_all = "snake_case")]
237#[display(style = "snake_case")]
238pub enum AppTheme {
239 Light,
241 Dark,
243 #[default]
246 System,
247}
248
249impl From<AppTheme> for kittycad::types::Color {
250 fn from(theme: AppTheme) -> Self {
251 match theme {
252 AppTheme::Light => kittycad::types::Color {
253 r: 249.0 / 255.0,
254 g: 249.0 / 255.0,
255 b: 249.0 / 255.0,
256 a: 1.0,
257 },
258 AppTheme::Dark => kittycad::types::Color {
259 r: 28.0 / 255.0,
260 g: 28.0 / 255.0,
261 b: 28.0 / 255.0,
262 a: 1.0,
263 },
264 AppTheme::System => {
265 todo!()
267 }
268 }
269 }
270}
271
272#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
274#[serde(rename_all = "snake_case")]
275#[ts(export)]
276pub struct ModelingSettings {
277 #[serde(default, skip_serializing_if = "is_default")]
279 pub base_unit: UnitLength,
280 #[serde(default, skip_serializing_if = "is_default")]
282 pub camera_projection: CameraProjectionType,
283 #[serde(default, skip_serializing_if = "is_default")]
285 pub camera_orbit: CameraOrbitType,
286 #[serde(default, skip_serializing_if = "is_default")]
288 pub mouse_controls: MouseControlType,
289 #[serde(default, skip_serializing_if = "is_default")]
291 pub enable_touch_controls: DefaultTrue,
292 #[serde(default, skip_serializing_if = "is_default")]
294 pub highlight_edges: DefaultTrue,
295 #[serde(default, skip_serializing_if = "is_default")]
297 pub enable_ssao: DefaultTrue,
298 #[serde(default, skip_serializing_if = "is_default")]
300 pub show_scale_grid: bool,
301}
302
303#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
304#[ts(export)]
305#[serde(transparent)]
306pub struct DefaultTrue(pub bool);
307
308impl Default for DefaultTrue {
309 fn default() -> Self {
310 Self(true)
311 }
312}
313
314impl From<DefaultTrue> for bool {
315 fn from(default_true: DefaultTrue) -> Self {
316 default_true.0
317 }
318}
319
320impl From<bool> for DefaultTrue {
321 fn from(b: bool) -> Self {
322 Self(b)
323 }
324}
325
326#[derive(
328 Debug, Default, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr,
329)]
330#[cfg_attr(
331 feature = "pyo3",
332 pyo3::pyclass(eq, eq_int),
333 pyo3_stub_gen::derive::gen_stub_pyclass_enum
334)]
335#[ts(export)]
336#[serde(rename_all = "lowercase")]
337#[display(style = "lowercase")]
338pub enum UnitLength {
339 Cm,
341 Ft,
343 In,
345 M,
347 #[default]
349 Mm,
350 Yd,
352}
353
354impl From<kittycad::types::UnitLength> for UnitLength {
355 fn from(unit: kittycad::types::UnitLength) -> Self {
356 match unit {
357 kittycad::types::UnitLength::Cm => UnitLength::Cm,
358 kittycad::types::UnitLength::Ft => UnitLength::Ft,
359 kittycad::types::UnitLength::In => UnitLength::In,
360 kittycad::types::UnitLength::M => UnitLength::M,
361 kittycad::types::UnitLength::Mm => UnitLength::Mm,
362 kittycad::types::UnitLength::Yd => UnitLength::Yd,
363 }
364 }
365}
366
367impl From<UnitLength> for kittycad::types::UnitLength {
368 fn from(unit: UnitLength) -> Self {
369 match unit {
370 UnitLength::Cm => kittycad::types::UnitLength::Cm,
371 UnitLength::Ft => kittycad::types::UnitLength::Ft,
372 UnitLength::In => kittycad::types::UnitLength::In,
373 UnitLength::M => kittycad::types::UnitLength::M,
374 UnitLength::Mm => kittycad::types::UnitLength::Mm,
375 UnitLength::Yd => kittycad::types::UnitLength::Yd,
376 }
377 }
378}
379
380impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
381 fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
382 match unit {
383 kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
384 kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
385 kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
386 kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
387 kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
388 kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
389 }
390 }
391}
392
393impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
394 fn from(unit: UnitLength) -> Self {
395 match unit {
396 UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
397 UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
398 UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
399 UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
400 UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
401 UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
402 }
403 }
404}
405
406#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
408#[ts(export)]
409#[serde(rename_all = "snake_case")]
410#[display(style = "snake_case")]
411pub enum MouseControlType {
412 #[default]
413 #[display("zoo")]
414 #[serde(rename = "zoo")]
415 Zoo,
416 #[display("onshape")]
417 #[serde(rename = "onshape")]
418 OnShape,
419 TrackpadFriendly,
420 Solidworks,
421 Nx,
422 Creo,
423 #[display("autocad")]
424 #[serde(rename = "autocad")]
425 AutoCad,
426}
427
428#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
430#[ts(export)]
431#[serde(rename_all = "snake_case")]
432#[display(style = "snake_case")]
433pub enum CameraProjectionType {
434 Perspective,
436 #[default]
438 Orthographic,
439}
440
441#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
443#[ts(export)]
444#[serde(rename_all = "snake_case")]
445#[display(style = "snake_case")]
446pub enum CameraOrbitType {
447 #[default]
449 #[display("spherical")]
450 Spherical,
451 #[display("trackball")]
453 Trackball,
454}
455
456#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
458#[serde(rename_all = "snake_case")]
459#[ts(export)]
460pub struct TextEditorSettings {
461 #[serde(default, skip_serializing_if = "is_default")]
463 pub text_wrapping: DefaultTrue,
464 #[serde(default, skip_serializing_if = "is_default")]
466 pub blinking_cursor: DefaultTrue,
467}
468
469#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
471#[serde(rename_all = "snake_case")]
472#[ts(export)]
473pub struct ProjectSettings {
474 #[serde(default, skip_serializing_if = "is_default")]
476 pub directory: std::path::PathBuf,
477 #[serde(default, skip_serializing_if = "is_default")]
479 pub default_project_name: ProjectNameTemplate,
480}
481
482#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
483#[ts(export)]
484#[serde(transparent)]
485pub struct ProjectNameTemplate(pub String);
486
487impl Default for ProjectNameTemplate {
488 fn default() -> Self {
489 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
490 }
491}
492
493impl From<ProjectNameTemplate> for String {
494 fn from(project_name: ProjectNameTemplate) -> Self {
495 project_name.0
496 }
497}
498
499impl From<String> for ProjectNameTemplate {
500 fn from(s: String) -> Self {
501 Self(s)
502 }
503}
504
505#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
507#[serde(rename_all = "snake_case")]
508#[ts(export)]
509pub struct CommandBarSettings {
510 #[serde(default, skip_serializing_if = "is_default")]
512 pub include_settings: DefaultTrue,
513}
514
515#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
517#[ts(export)]
518#[serde(rename_all = "snake_case")]
519#[display(style = "snake_case")]
520pub enum OnboardingStatus {
521 #[serde(rename = "")]
523 #[display("")]
524 Unset,
525 Completed,
527 #[default]
529 Incomplete,
530 Dismissed,
532
533 #[serde(rename = "/desktop")]
535 #[display("/desktop")]
536 DesktopWelcome,
537 #[serde(rename = "/desktop/scene")]
538 #[display("/desktop/scene")]
539 DesktopScene,
540 #[serde(rename = "/desktop/toolbar")]
541 #[display("/desktop/toolbar")]
542 DesktopToolbar,
543 #[serde(rename = "/desktop/text-to-cad")]
544 #[display("/desktop/text-to-cad")]
545 DesktopTextToCadWelcome,
546 #[serde(rename = "/desktop/text-to-cad-prompt")]
547 #[display("/desktop/text-to-cad-prompt")]
548 DesktopTextToCadPrompt,
549 #[serde(rename = "/desktop/feature-tree-pane")]
550 #[display("/desktop/feature-tree-pane")]
551 DesktopFeatureTreePane,
552 #[serde(rename = "/desktop/code-pane")]
553 #[display("/desktop/code-pane")]
554 DesktopCodePane,
555 #[serde(rename = "/desktop/project-pane")]
556 #[display("/desktop/project-pane")]
557 DesktopProjectFilesPane,
558 #[serde(rename = "/desktop/other-panes")]
559 #[display("/desktop/other-panes")]
560 DesktopOtherPanes,
561 #[serde(rename = "/desktop/prompt-to-edit")]
562 #[display("/desktop/prompt-to-edit")]
563 DesktopPromptToEditWelcome,
564 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
565 #[display("/desktop/prompt-to-edit-prompt")]
566 DesktopPromptToEditPrompt,
567 #[serde(rename = "/desktop/prompt-to-edit-result")]
568 #[display("/desktop/prompt-to-edit-result")]
569 DesktopPromptToEditResult,
570 #[serde(rename = "/desktop/imports")]
571 #[display("/desktop/imports")]
572 DesktopImports,
573 #[serde(rename = "/desktop/exports")]
574 #[display("/desktop/exports")]
575 DesktopExports,
576 #[serde(rename = "/desktop/conclusion")]
577 #[display("/desktop/conclusion")]
578 DesktopConclusion,
579
580 #[serde(rename = "/browser")]
582 #[display("/browser")]
583 BrowserWelcome,
584 #[serde(rename = "/browser/scene")]
585 #[display("/browser/scene")]
586 BrowserScene,
587 #[serde(rename = "/browser/toolbar")]
588 #[display("/browser/toolbar")]
589 BrowserToolbar,
590 #[serde(rename = "/browser/text-to-cad")]
591 #[display("/browser/text-to-cad")]
592 BrowserTextToCadWelcome,
593 #[serde(rename = "/browser/text-to-cad-prompt")]
594 #[display("/browser/text-to-cad-prompt")]
595 BrowserTextToCadPrompt,
596 #[serde(rename = "/browser/feature-tree-pane")]
597 #[display("/browser/feature-tree-pane")]
598 BrowserFeatureTreePane,
599 #[serde(rename = "/browser/prompt-to-edit")]
600 #[display("/browser/prompt-to-edit")]
601 BrowserPromptToEditWelcome,
602 #[serde(rename = "/browser/prompt-to-edit-prompt")]
603 #[display("/browser/prompt-to-edit-prompt")]
604 BrowserPromptToEditPrompt,
605 #[serde(rename = "/browser/prompt-to-edit-result")]
606 #[display("/browser/prompt-to-edit-result")]
607 BrowserPromptToEditResult,
608 #[serde(rename = "/browser/conclusion")]
609 #[display("/browser/conclusion")]
610 BrowserConclusion,
611}
612
613fn is_default<T: Default + PartialEq>(t: &T) -> bool {
614 t == &T::default()
615}
616
617#[cfg(test)]
618mod tests {
619 use pretty_assertions::assert_eq;
620 use validator::Validate;
621
622 use super::{
623 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
624 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
625 TextEditorSettings, UnitLength,
626 };
627
628 #[test]
629 fn test_settings_empty_file_parses() {
630 let empty_settings_file = r#""#;
631
632 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
633 assert_eq!(parsed, Configuration::default());
634
635 let serialized = toml::to_string(&parsed).unwrap();
637 assert_eq!(serialized, r#""#);
638
639 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
640 assert_eq!(parsed, Configuration::default());
641 }
642
643 #[test]
644 fn test_settings_parse_basic() {
645 let settings_file = r#"[settings.app]
646default_project_name = "untitled"
647directory = ""
648onboarding_status = "dismissed"
649
650 [settings.app.appearance]
651 theme = "dark"
652
653[settings.modeling]
654enable_ssao = false
655base_unit = "in"
656mouse_controls = "zoo"
657camera_projection = "perspective"
658
659[settings.project]
660default_project_name = "untitled"
661directory = ""
662
663[settings.text_editor]
664text_wrapping = true"#;
665
666 let expected = Configuration {
667 settings: Settings {
668 app: AppSettings {
669 onboarding_status: OnboardingStatus::Dismissed,
670 appearance: AppearanceSettings {
671 theme: AppTheme::Dark,
672 color: AppColor(264.5),
673 },
674 ..Default::default()
675 },
676 modeling: ModelingSettings {
677 enable_ssao: false.into(),
678 base_unit: UnitLength::In,
679 mouse_controls: MouseControlType::Zoo,
680 camera_projection: CameraProjectionType::Perspective,
681 ..Default::default()
682 },
683 project: ProjectSettings {
684 default_project_name: ProjectNameTemplate("untitled".to_string()),
685 directory: "".into(),
686 },
687 text_editor: TextEditorSettings {
688 text_wrapping: true.into(),
689 ..Default::default()
690 },
691 command_bar: CommandBarSettings {
692 include_settings: true.into(),
693 },
694 },
695 };
696 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
697 assert_eq!(parsed, expected);
698
699 let serialized = toml::to_string(&parsed).unwrap();
701 assert_eq!(
702 serialized,
703 r#"[settings.app]
704onboarding_status = "dismissed"
705
706[settings.app.appearance]
707theme = "dark"
708
709[settings.modeling]
710base_unit = "in"
711camera_projection = "perspective"
712enable_ssao = false
713"#
714 );
715
716 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
717 assert_eq!(parsed, expected);
718 }
719
720 #[test]
721 fn test_color_validation() {
722 let color = AppColor(360.0);
723
724 let result = color.validate();
725 if let Ok(r) = result {
726 panic!("Expected an error, but got success: {r:?}");
727 }
728 assert!(result.is_err());
729 assert!(
730 result
731 .unwrap_err()
732 .to_string()
733 .contains("color: Validation error: color")
734 );
735
736 let appearance = AppearanceSettings {
737 theme: AppTheme::System,
738 color: AppColor(361.5),
739 };
740 let result = appearance.validate();
741 if let Ok(r) = result {
742 panic!("Expected an error, but got success: {r:?}");
743 }
744 assert!(result.is_err());
745 assert!(
746 result
747 .unwrap_err()
748 .to_string()
749 .contains("color: Validation error: color")
750 );
751 }
752
753 #[test]
754 fn test_settings_color_validation_error() {
755 let settings_file = r#"[settings.app.appearance]
756color = 1567.4"#;
757
758 let result = Configuration::parse_and_validate(settings_file);
759 if let Ok(r) = result {
760 panic!("Expected an error, but got success: {r:?}");
761 }
762 assert!(result.is_err());
763
764 assert!(
765 result
766 .unwrap_err()
767 .to_string()
768 .contains("color: Validation error: color")
769 );
770 }
771}