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(feature = "pyo3", pyo3::pyclass(eq, eq_int))]
331#[ts(export)]
332#[serde(rename_all = "lowercase")]
333#[display(style = "lowercase")]
334pub enum UnitLength {
335 Cm,
337 Ft,
339 In,
341 M,
343 #[default]
345 Mm,
346 Yd,
348}
349
350impl From<kittycad::types::UnitLength> for UnitLength {
351 fn from(unit: kittycad::types::UnitLength) -> Self {
352 match unit {
353 kittycad::types::UnitLength::Cm => UnitLength::Cm,
354 kittycad::types::UnitLength::Ft => UnitLength::Ft,
355 kittycad::types::UnitLength::In => UnitLength::In,
356 kittycad::types::UnitLength::M => UnitLength::M,
357 kittycad::types::UnitLength::Mm => UnitLength::Mm,
358 kittycad::types::UnitLength::Yd => UnitLength::Yd,
359 }
360 }
361}
362
363impl From<UnitLength> for kittycad::types::UnitLength {
364 fn from(unit: UnitLength) -> Self {
365 match unit {
366 UnitLength::Cm => kittycad::types::UnitLength::Cm,
367 UnitLength::Ft => kittycad::types::UnitLength::Ft,
368 UnitLength::In => kittycad::types::UnitLength::In,
369 UnitLength::M => kittycad::types::UnitLength::M,
370 UnitLength::Mm => kittycad::types::UnitLength::Mm,
371 UnitLength::Yd => kittycad::types::UnitLength::Yd,
372 }
373 }
374}
375
376impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
377 fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
378 match unit {
379 kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
380 kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
381 kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
382 kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
383 kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
384 kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
385 }
386 }
387}
388
389impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
390 fn from(unit: UnitLength) -> Self {
391 match unit {
392 UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
393 UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
394 UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
395 UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
396 UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
397 UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
398 }
399 }
400}
401
402#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
404#[ts(export)]
405#[serde(rename_all = "snake_case")]
406#[display(style = "snake_case")]
407pub enum MouseControlType {
408 #[default]
409 #[display("zoo")]
410 #[serde(rename = "zoo")]
411 Zoo,
412 #[display("onshape")]
413 #[serde(rename = "onshape")]
414 OnShape,
415 TrackpadFriendly,
416 Solidworks,
417 Nx,
418 Creo,
419 #[display("autocad")]
420 #[serde(rename = "autocad")]
421 AutoCad,
422}
423
424#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
426#[ts(export)]
427#[serde(rename_all = "snake_case")]
428#[display(style = "snake_case")]
429pub enum CameraProjectionType {
430 Perspective,
432 #[default]
434 Orthographic,
435}
436
437#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
439#[ts(export)]
440#[serde(rename_all = "snake_case")]
441#[display(style = "snake_case")]
442pub enum CameraOrbitType {
443 #[default]
445 #[display("spherical")]
446 Spherical,
447 #[display("trackball")]
449 Trackball,
450}
451
452#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
454#[serde(rename_all = "snake_case")]
455#[ts(export)]
456pub struct TextEditorSettings {
457 #[serde(default, skip_serializing_if = "is_default")]
459 pub text_wrapping: DefaultTrue,
460 #[serde(default, skip_serializing_if = "is_default")]
462 pub blinking_cursor: DefaultTrue,
463}
464
465#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
467#[serde(rename_all = "snake_case")]
468#[ts(export)]
469pub struct ProjectSettings {
470 #[serde(default, skip_serializing_if = "is_default")]
472 pub directory: std::path::PathBuf,
473 #[serde(default, skip_serializing_if = "is_default")]
475 pub default_project_name: ProjectNameTemplate,
476}
477
478#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
479#[ts(export)]
480#[serde(transparent)]
481pub struct ProjectNameTemplate(pub String);
482
483impl Default for ProjectNameTemplate {
484 fn default() -> Self {
485 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
486 }
487}
488
489impl From<ProjectNameTemplate> for String {
490 fn from(project_name: ProjectNameTemplate) -> Self {
491 project_name.0
492 }
493}
494
495impl From<String> for ProjectNameTemplate {
496 fn from(s: String) -> Self {
497 Self(s)
498 }
499}
500
501#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
503#[serde(rename_all = "snake_case")]
504#[ts(export)]
505pub struct CommandBarSettings {
506 #[serde(default, skip_serializing_if = "is_default")]
508 pub include_settings: DefaultTrue,
509}
510
511#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
513#[ts(export)]
514#[serde(rename_all = "snake_case")]
515#[display(style = "snake_case")]
516pub enum OnboardingStatus {
517 #[serde(rename = "")]
519 #[display("")]
520 Unset,
521 Completed,
523 #[default]
525 Incomplete,
526 Dismissed,
528
529 #[serde(rename = "/desktop")]
531 #[display("/desktop")]
532 DesktopWelcome,
533 #[serde(rename = "/desktop/scene")]
534 #[display("/desktop/scene")]
535 DesktopScene,
536 #[serde(rename = "/desktop/toolbar")]
537 #[display("/desktop/toolbar")]
538 DesktopToolbar,
539 #[serde(rename = "/desktop/text-to-cad")]
540 #[display("/desktop/text-to-cad")]
541 DesktopTextToCadWelcome,
542 #[serde(rename = "/desktop/text-to-cad-prompt")]
543 #[display("/desktop/text-to-cad-prompt")]
544 DesktopTextToCadPrompt,
545 #[serde(rename = "/desktop/feature-tree-pane")]
546 #[display("/desktop/feature-tree-pane")]
547 DesktopFeatureTreePane,
548 #[serde(rename = "/desktop/code-pane")]
549 #[display("/desktop/code-pane")]
550 DesktopCodePane,
551 #[serde(rename = "/desktop/project-pane")]
552 #[display("/desktop/project-pane")]
553 DesktopProjectFilesPane,
554 #[serde(rename = "/desktop/other-panes")]
555 #[display("/desktop/other-panes")]
556 DesktopOtherPanes,
557 #[serde(rename = "/desktop/prompt-to-edit")]
558 #[display("/desktop/prompt-to-edit")]
559 DesktopPromptToEditWelcome,
560 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
561 #[display("/desktop/prompt-to-edit-prompt")]
562 DesktopPromptToEditPrompt,
563 #[serde(rename = "/desktop/prompt-to-edit-result")]
564 #[display("/desktop/prompt-to-edit-result")]
565 DesktopPromptToEditResult,
566 #[serde(rename = "/desktop/imports")]
567 #[display("/desktop/imports")]
568 DesktopImports,
569 #[serde(rename = "/desktop/exports")]
570 #[display("/desktop/exports")]
571 DesktopExports,
572 #[serde(rename = "/desktop/conclusion")]
573 #[display("/desktop/conclusion")]
574 DesktopConclusion,
575
576 #[serde(rename = "/browser")]
578 #[display("/browser")]
579 BrowserWelcome,
580 #[serde(rename = "/browser/scene")]
581 #[display("/browser/scene")]
582 BrowserScene,
583 #[serde(rename = "/browser/toolbar")]
584 #[display("/browser/toolbar")]
585 BrowserToolbar,
586 #[serde(rename = "/browser/text-to-cad")]
587 #[display("/browser/text-to-cad")]
588 BrowserTextToCadWelcome,
589 #[serde(rename = "/browser/text-to-cad-prompt")]
590 #[display("/browser/text-to-cad-prompt")]
591 BrowserTextToCadPrompt,
592 #[serde(rename = "/browser/feature-tree-pane")]
593 #[display("/browser/feature-tree-pane")]
594 BrowserFeatureTreePane,
595 #[serde(rename = "/browser/prompt-to-edit")]
596 #[display("/browser/prompt-to-edit")]
597 BrowserPromptToEditWelcome,
598 #[serde(rename = "/browser/prompt-to-edit-prompt")]
599 #[display("/browser/prompt-to-edit-prompt")]
600 BrowserPromptToEditPrompt,
601 #[serde(rename = "/browser/prompt-to-edit-result")]
602 #[display("/browser/prompt-to-edit-result")]
603 BrowserPromptToEditResult,
604 #[serde(rename = "/browser/conclusion")]
605 #[display("/browser/conclusion")]
606 BrowserConclusion,
607}
608
609fn is_default<T: Default + PartialEq>(t: &T) -> bool {
610 t == &T::default()
611}
612
613#[cfg(test)]
614mod tests {
615 use pretty_assertions::assert_eq;
616 use validator::Validate;
617
618 use super::{
619 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
620 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
621 TextEditorSettings, UnitLength,
622 };
623
624 #[test]
625 fn test_settings_empty_file_parses() {
626 let empty_settings_file = r#""#;
627
628 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
629 assert_eq!(parsed, Configuration::default());
630
631 let serialized = toml::to_string(&parsed).unwrap();
633 assert_eq!(serialized, r#""#);
634
635 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
636 assert_eq!(parsed, Configuration::default());
637 }
638
639 #[test]
640 fn test_settings_parse_basic() {
641 let settings_file = r#"[settings.app]
642default_project_name = "untitled"
643directory = ""
644onboarding_status = "dismissed"
645
646 [settings.app.appearance]
647 theme = "dark"
648
649[settings.modeling]
650enable_ssao = false
651base_unit = "in"
652mouse_controls = "zoo"
653camera_projection = "perspective"
654
655[settings.project]
656default_project_name = "untitled"
657directory = ""
658
659[settings.text_editor]
660text_wrapping = true"#;
661
662 let expected = Configuration {
663 settings: Settings {
664 app: AppSettings {
665 onboarding_status: OnboardingStatus::Dismissed,
666 appearance: AppearanceSettings {
667 theme: AppTheme::Dark,
668 color: AppColor(264.5),
669 },
670 ..Default::default()
671 },
672 modeling: ModelingSettings {
673 enable_ssao: false.into(),
674 base_unit: UnitLength::In,
675 mouse_controls: MouseControlType::Zoo,
676 camera_projection: CameraProjectionType::Perspective,
677 ..Default::default()
678 },
679 project: ProjectSettings {
680 default_project_name: ProjectNameTemplate("untitled".to_string()),
681 directory: "".into(),
682 },
683 text_editor: TextEditorSettings {
684 text_wrapping: true.into(),
685 ..Default::default()
686 },
687 command_bar: CommandBarSettings {
688 include_settings: true.into(),
689 },
690 },
691 };
692 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
693 assert_eq!(parsed, expected);
694
695 let serialized = toml::to_string(&parsed).unwrap();
697 assert_eq!(
698 serialized,
699 r#"[settings.app]
700onboarding_status = "dismissed"
701
702[settings.app.appearance]
703theme = "dark"
704
705[settings.modeling]
706base_unit = "in"
707camera_projection = "perspective"
708enable_ssao = false
709"#
710 );
711
712 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
713 assert_eq!(parsed, expected);
714 }
715
716 #[test]
717 fn test_color_validation() {
718 let color = AppColor(360.0);
719
720 let result = color.validate();
721 if let Ok(r) = result {
722 panic!("Expected an error, but got success: {r:?}");
723 }
724 assert!(result.is_err());
725 assert!(
726 result
727 .unwrap_err()
728 .to_string()
729 .contains("color: Validation error: color")
730 );
731
732 let appearance = AppearanceSettings {
733 theme: AppTheme::System,
734 color: AppColor(361.5),
735 };
736 let result = appearance.validate();
737 if let Ok(r) = result {
738 panic!("Expected an error, but got success: {r:?}");
739 }
740 assert!(result.is_err());
741 assert!(
742 result
743 .unwrap_err()
744 .to_string()
745 .contains("color: Validation error: color")
746 );
747 }
748
749 #[test]
750 fn test_settings_color_validation_error() {
751 let settings_file = r#"[settings.app.appearance]
752color = 1567.4"#;
753
754 let result = Configuration::parse_and_validate(settings_file);
755 if let Ok(r) = result {
756 panic!("Expected an error, but got success: {r:?}");
757 }
758 assert!(result.is_err());
759
760 assert!(
761 result
762 .unwrap_err()
763 .to_string()
764 .contains("color: Validation error: color")
765 );
766 }
767}