1use std::collections::BTreeMap;
25
26use fig::ExtKind;
27use fig_schema::FieldType;
28
29use crate::textdist::nearest;
30use prov_exports::{ExportIssueKind, ExportSpec};
31use prov_graph::content::ContentFormat;
32use prov_graph::document::EmbedStyle;
33pub use prov_graph::fixity::Fixity;
34use prov_graph::identity::{Registration, Trigger};
35use prov_graph::link::{Addressing, LinkStyle, Notation, PathStyle, ReferenceStyle};
36use prov_graph::meta::{Mapping, Value};
37use prov_graph::relation::{Cardinality, Relation, RelationSet};
38use prov_views::{ViewIssueKind, ViewSpec};
39
40pub use prov_graph::identity::IdStorage;
44
45pub const SPEC_VERSION: i64 = 1;
49
50pub const ROOT_CONFIG_KEY: &str = "prov";
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub struct RelationStyleConfig {
67 pub notation: Option<Notation>,
69 pub path_style: Option<PathStyle>,
71 pub target: Option<Addressing>,
73 pub label: Option<bool>,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct RelationDef {
87 pub cardinality: Option<Cardinality>,
91 pub inverse: Option<String>,
93 pub means: Option<String>,
97}
98
99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
104pub enum OpenClosed {
105 #[default]
108 Open,
109 Closed,
113}
114
115impl OpenClosed {
116 pub fn from_config_str(value: &str) -> Option<Self> {
118 match value {
119 "open" => Some(Self::Open),
120 "closed" => Some(Self::Closed),
121 _ => None,
122 }
123 }
124
125 pub fn as_config_str(self) -> &'static str {
127 match self {
128 Self::Open => "open",
129 Self::Closed => "closed",
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct FieldSpec {
153 pub ty: Option<FieldType>,
157 pub values: OpenClosed,
160 pub vocabulary: Option<String>,
164 pub reify: bool,
168}
169
170pub const FIELD_TYPES: &[&str] = &[
178 "str",
179 "bool",
180 "int",
181 "float",
182 "date",
183 "datetime",
184 "local-datetime",
185 "time",
186 "ref",
187 "map",
188 "seq",
189];
190
191pub fn field_type_from_config_str(value: &str) -> Option<FieldType> {
204 Some(match value {
205 "str" => FieldType::Str,
206 "bool" => FieldType::Bool,
207 "int" => FieldType::Int,
208 "float" => FieldType::Float,
209 "datetime" => FieldType::Extended(ExtKind::OffsetDateTime),
212 "local-datetime" => FieldType::Extended(ExtKind::LocalDateTime),
213 "date" => FieldType::Extended(ExtKind::LocalDate),
214 "time" => FieldType::Extended(ExtKind::LocalTime),
215 "ref" => FieldType::Ref,
216 "map" => FieldType::Map,
217 "seq" => FieldType::Seq,
218 _ => return None,
219 })
220}
221
222pub fn field_type_as_config_str(ty: FieldType) -> Option<&'static str> {
226 Some(match ty {
227 FieldType::Str => "str",
228 FieldType::Bool => "bool",
229 FieldType::Int => "int",
230 FieldType::Float => "float",
231 FieldType::Ref => "ref",
232 FieldType::Map => "map",
233 FieldType::Seq => "seq",
234 FieldType::Extended(ExtKind::OffsetDateTime) => "datetime",
235 FieldType::Extended(ExtKind::LocalDateTime) => "local-datetime",
236 FieldType::Extended(ExtKind::LocalDate) => "date",
237 FieldType::Extended(ExtKind::LocalTime) => "time",
238 FieldType::Null | FieldType::Extended(_) => return None,
239 _ => return None,
244 })
245}
246
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
272pub enum About {
273 Off,
275 #[default]
281 Structure,
282}
283
284impl About {
285 pub fn generates(self) -> bool {
287 matches!(self, About::Structure)
288 }
289
290 pub fn from_config_str(value: &str) -> Option<Self> {
292 match value {
293 "off" => Some(Self::Off),
294 "structure" => Some(Self::Structure),
295 _ => None,
296 }
297 }
298
299 pub fn as_config_str(self) -> &'static str {
301 match self {
302 Self::Off => "off",
303 Self::Structure => "structure",
304 }
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct WorkspaceConfig {
311 pub identity: Registration,
313 pub notation: Notation,
316 pub path_style: PathStyle,
319 pub reference_target: Addressing,
321 pub reference_label: bool,
323 pub relation_styles: BTreeMap<String, RelationStyleConfig>,
330 pub spanning: Option<String>,
335 pub relation_defs: BTreeMap<String, RelationDef>,
340 pub fields: BTreeMap<String, FieldSpec>,
344 pub views: Vec<ViewSpec>,
357 pub exports: Vec<ExportSpec>,
368 pub id_storage: IdStorage,
371 pub default_embed_format: fig::Format,
374 pub embed_style: EmbedStyle,
381 pub content_format: ContentFormat,
385 pub recycle_bin: bool,
390 pub fixity: Fixity,
393 pub about: About,
396 pub updated: String,
404 pub workspace_id: String,
418}
419
420pub use prov_graph::link::is_valid_workspace_id;
427
428impl Default for WorkspaceConfig {
429 fn default() -> Self {
433 Self {
434 identity: Registration::LAZY,
435 notation: Notation::Markdown,
436 path_style: PathStyle::Root,
437 reference_target: Addressing::Path,
438 reference_label: false,
439 relation_styles: BTreeMap::new(),
440 spanning: None,
441 relation_defs: BTreeMap::new(),
442 fields: BTreeMap::new(),
443 views: Vec::new(),
444 exports: Vec::new(),
445 id_storage: IdStorage::Frontmatter,
446 default_embed_format: fig::Format::Yaml,
447 embed_style: EmbedStyle::Delimited,
448 content_format: ContentFormat::Markdown,
449 recycle_bin: true,
450 fixity: Fixity::Payloads,
451 about: About::Structure,
452 updated: String::new(),
453 workspace_id: String::new(),
454 }
455 }
456}
457
458impl WorkspaceConfig {
459 pub fn paths_only() -> Self {
462 Self {
463 identity: Registration::OFF,
464 id_storage: IdStorage::Registry,
465 ..Self::default()
466 }
467 }
468
469 pub fn stable_ids() -> Self {
473 Self {
474 identity: Registration::LAZY,
475 reference_target: Addressing::Id,
476 id_storage: IdStorage::Registry,
477 ..Self::default()
478 }
479 }
480
481 pub fn link_format(&self) -> LinkStyle {
485 LinkStyle::from_axes(self.notation, self.path_style)
486 }
487
488 pub fn reference_style(&self) -> ReferenceStyle {
491 ReferenceStyle {
492 wrapper: self.notation.wrapper(),
493 addressing: self.reference_target,
494 label: self.reference_label,
495 path_style: LinkStyle::from_axes(self.notation, self.path_style),
496 }
497 .normalized()
498 }
499
500 pub fn resolved_relation_styles(&self) -> BTreeMap<String, ReferenceStyle> {
509 let base = self.reference_style();
510 let base_notation = Notation::from_wrapper(base.wrapper, base.path_style);
511 let base_path = base.path_style.axes().1;
512 self.relation_styles
513 .iter()
514 .map(|(name, over)| {
515 let notation = over.notation.unwrap_or(base_notation);
516 let path = over.path_style.unwrap_or(base_path);
517 let style = ReferenceStyle {
518 wrapper: notation.wrapper(),
519 addressing: over.target.unwrap_or(base.addressing),
520 label: over.label.unwrap_or(base.label),
521 path_style: LinkStyle::from_axes(notation, path),
522 }
523 .normalized();
524 (name.clone(), style)
525 })
526 .collect()
527 }
528
529 pub fn relation_set(&self) -> RelationSet {
539 let mut set = if self.relation_defs.is_empty() {
540 RelationSet::diaryx()
541 } else {
542 let mut s = RelationSet::new();
543 for (name, def) in &self.relation_defs {
544 let mut rel = match def.cardinality.unwrap_or(Cardinality::Many) {
545 Cardinality::One => Relation::one(name),
546 Cardinality::Many => Relation::many(name),
547 };
548 if let Some(inverse) = &def.inverse {
549 rel = rel.inverse(inverse);
550 }
551 s = s.with(rel);
552 }
553 for pointer in ["registry", "config", "recycle_bin", "history", "about"] {
556 if !s.relations().iter().any(|r| r.name == pointer) {
557 s = s.with(Relation::one(pointer));
558 }
559 }
560 s.registry("registry")
561 .config("config")
562 .recycle("recycle_bin")
563 .history("history")
564 .about("about")
565 };
566 if let Some(spanning) = &self.spanning {
567 set = set.spanning(spanning);
568 }
569 set.with_styles(&self.resolved_relation_styles())
570 }
571
572 pub fn mints_on_mutation(&self) -> bool {
587 let link_registers = self.reference_style().registers()
588 || self
589 .resolved_relation_styles()
590 .values()
591 .any(|s| s.registers());
592 (link_registers && self.identity.fires_on(Trigger::Link))
593 || self.identity.fires_on(Trigger::Create)
594 }
595
596 pub fn apply(&mut self, meta: &Value) {
601 if let Some(v) = meta
602 .get("content_format")
603 .and_then(Value::as_str)
604 .and_then(ContentFormat::from_config_str)
605 {
606 self.content_format = v;
607 }
608 if let Some(md) = meta.get("metadata") {
609 if let Some(v) = md
610 .get("format")
611 .and_then(Value::as_str)
612 .and_then(format_from_str)
613 {
614 self.default_embed_format = v;
615 }
616 if let Some(v) = md
617 .get("embed")
618 .and_then(Value::as_str)
619 .and_then(EmbedStyle::from_config_str)
620 {
621 self.embed_style = v;
622 }
623 }
624 if let Some(rf) = meta.get("references") {
625 if let Some(v) = rf
626 .get("notation")
627 .and_then(Value::as_str)
628 .and_then(Notation::from_config_str)
629 {
630 self.notation = v;
631 }
632 if let Some(v) = rf
633 .get("path_style")
634 .and_then(Value::as_str)
635 .and_then(PathStyle::from_config_str)
636 {
637 self.path_style = v;
638 }
639 if let Some(v) = rf
640 .get("target")
641 .and_then(Value::as_str)
642 .and_then(Addressing::from_config_str)
643 {
644 self.reference_target = v;
645 }
646 if let Some(v) = rf.get("label").and_then(Value::as_bool) {
647 self.reference_label = v;
648 }
649 }
650 if let Some(v) = meta.get("spanning").and_then(Value::as_str) {
652 self.spanning = Some(v.to_string());
653 }
654 if let Some(v) = meta
658 .get("workspace_id")
659 .and_then(Value::as_str)
660 .filter(|v| is_valid_workspace_id(v))
661 {
662 self.workspace_id = v.to_string();
663 }
664 if let Some(relations) = meta.get("relations").and_then(Value::as_mapping) {
668 for (name, spec) in relations {
669 let entry = self.relation_styles.entry(name.clone()).or_default();
670 if let Some(v) = spec
671 .get("notation")
672 .and_then(Value::as_str)
673 .and_then(Notation::from_config_str)
674 {
675 entry.notation = Some(v);
676 }
677 if let Some(v) = spec
678 .get("path_style")
679 .and_then(Value::as_str)
680 .and_then(PathStyle::from_config_str)
681 {
682 entry.path_style = Some(v);
683 }
684 if let Some(v) = spec
685 .get("target")
686 .and_then(Value::as_str)
687 .and_then(Addressing::from_config_str)
688 {
689 entry.target = Some(v);
690 }
691 if let Some(v) = spec.get("label").and_then(Value::as_bool) {
692 entry.label = Some(v);
693 }
694 let cardinality = spec
697 .get("cardinality")
698 .and_then(Value::as_str)
699 .and_then(cardinality_from_str);
700 let inverse = spec
701 .get("inverse")
702 .and_then(Value::as_str)
703 .map(str::to_string);
704 let means = spec
705 .get("means")
706 .and_then(Value::as_str)
707 .map(str::to_string);
708 if cardinality.is_some() || inverse.is_some() || means.is_some() {
709 let def = self.relation_defs.entry(name.clone()).or_default();
710 if cardinality.is_some() {
711 def.cardinality = cardinality;
712 }
713 if inverse.is_some() {
714 def.inverse = inverse;
715 }
716 if means.is_some() {
717 def.means = means;
718 }
719 }
720 }
721 }
722 if let Some(fields) = meta.get("fields").and_then(Value::as_mapping) {
724 for (name, spec) in fields {
725 let vocabulary = spec
726 .get("vocabulary")
727 .and_then(Value::as_str)
728 .map(str::to_string);
729 let ty = spec
730 .get("type")
731 .and_then(Value::as_str)
732 .and_then(field_type_from_config_str);
733 if ty.is_none() && vocabulary.is_none() {
739 continue;
740 }
741 let values = spec
742 .get("values")
743 .and_then(Value::as_str)
744 .and_then(OpenClosed::from_config_str)
745 .unwrap_or_default();
746 let reify = spec.get("reify").and_then(Value::as_bool).unwrap_or(false);
747 self.fields.insert(
748 name.clone(),
749 FieldSpec {
750 ty,
751 values,
752 vocabulary,
753 reify,
754 },
755 );
756 }
757 }
758 if let Some(views) = meta.get(prov_views::VIEWS_KEY).and_then(Value::as_mapping) {
767 for (name, value) in views {
768 let Some(spec) = ViewSpec::parse(name, value) else {
769 continue;
770 };
771 match self.views.iter_mut().find(|v| v.name == spec.name) {
772 Some(existing) => *existing = spec,
773 None => self.views.push(spec),
774 }
775 }
776 }
777 if let Some(exports) = meta
784 .get(prov_exports::EXPORTS_KEY)
785 .and_then(Value::as_mapping)
786 {
787 for (name, value) in exports {
788 let Some(spec) = ExportSpec::parse(name, value) else {
789 continue;
790 };
791 match self.exports.iter_mut().find(|e| e.name == spec.name) {
792 Some(existing) => *existing = spec,
793 None => self.exports.push(spec),
794 }
795 }
796 }
797 if let Some(v) = meta
798 .get("id_storage")
799 .and_then(Value::as_str)
800 .and_then(IdStorage::from_config_str)
801 {
802 self.id_storage = v;
803 }
804 if let Some(v) = meta.get("updated").and_then(Value::as_str) {
805 self.updated = v.to_string();
806 }
807 if let Some(v) = meta
808 .get("identity")
809 .and_then(Value::as_str)
810 .and_then(registration_from_str)
811 {
812 self.identity = v;
813 }
814 if let Some(v) = meta
815 .get("fixity")
816 .and_then(Value::as_str)
817 .and_then(Fixity::from_config_str)
818 {
819 self.fixity = v;
820 }
821 if let Some(v) = meta.get("recycle_bin").and_then(Value::as_bool) {
822 self.recycle_bin = v;
823 }
824 if let Some(v) = meta
825 .get("about")
826 .and_then(Value::as_str)
827 .and_then(About::from_config_str)
828 {
829 self.about = v;
830 }
831 }
832
833 pub fn from_meta(meta: &Value) -> Self {
835 let mut config = Self::default();
836 config.apply(meta);
837 config
838 }
839
840 pub fn to_mapping(&self) -> Mapping {
844 let mut map = Mapping::new();
845 map.insert("spec".into(), Value::Int(SPEC_VERSION));
846 map.insert(
847 "content_format".into(),
848 Value::String(self.content_format.as_config_str().into()),
849 );
850
851 let mut metadata = Mapping::new();
852 metadata.insert(
853 "format".into(),
854 Value::String(format_str(self.default_embed_format).into()),
855 );
856 metadata.insert(
857 "embed".into(),
858 Value::String(self.embed_style.as_config_str().into()),
859 );
860 map.insert("metadata".into(), Value::Mapping(metadata));
861
862 let mut references = Mapping::new();
863 references.insert(
864 "notation".into(),
865 Value::String(self.notation.as_config_str().into()),
866 );
867 references.insert(
868 "path_style".into(),
869 Value::String(self.path_style.as_config_str().into()),
870 );
871 references.insert(
872 "target".into(),
873 Value::String(self.reference_target.as_config_str().into()),
874 );
875 references.insert("label".into(), Value::Bool(self.reference_label));
876 map.insert("references".into(), Value::Mapping(references));
877
878 if let Some(spanning) = &self.spanning {
879 map.insert("spanning".into(), Value::String(spanning.clone()));
880 }
881
882 if !self.relation_styles.is_empty() || !self.relation_defs.is_empty() {
886 let mut names: Vec<&String> = self
887 .relation_styles
888 .keys()
889 .chain(self.relation_defs.keys())
890 .collect();
891 names.sort();
892 names.dedup();
893 let mut relations = Mapping::new();
894 for name in names {
895 let mut spec = Mapping::new();
896 if let Some(over) = self.relation_styles.get(name) {
897 if let Some(n) = over.notation {
898 spec.insert("notation".into(), Value::String(n.as_config_str().into()));
899 }
900 if let Some(p) = over.path_style {
901 spec.insert("path_style".into(), Value::String(p.as_config_str().into()));
902 }
903 if let Some(t) = over.target {
904 spec.insert("target".into(), Value::String(t.as_config_str().into()));
905 }
906 if let Some(l) = over.label {
907 spec.insert("label".into(), Value::Bool(l));
908 }
909 }
910 if let Some(def) = self.relation_defs.get(name) {
911 if let Some(c) = def.cardinality {
912 spec.insert(
913 "cardinality".into(),
914 Value::String(cardinality_str(c).into()),
915 );
916 }
917 if let Some(inv) = &def.inverse {
918 spec.insert("inverse".into(), Value::String(inv.clone()));
919 }
920 if let Some(m) = &def.means {
921 spec.insert("means".into(), Value::String(m.clone()));
922 }
923 }
924 relations.insert(name.clone(), Value::Mapping(spec));
925 }
926 map.insert("relations".into(), Value::Mapping(relations));
927 }
928
929 if !self.fields.is_empty() {
930 let mut fields = Mapping::new();
931 for (name, spec) in &self.fields {
932 let mut entry = Mapping::new();
933 if let Some(ty) = spec.ty.and_then(field_type_as_config_str) {
934 entry.insert("type".into(), Value::String(ty.into()));
935 }
936 if let Some(vocabulary) = &spec.vocabulary {
939 entry.insert(
940 "values".into(),
941 Value::String(spec.values.as_config_str().into()),
942 );
943 entry.insert("vocabulary".into(), Value::String(vocabulary.clone()));
944 }
945 if spec.reify {
946 entry.insert("reify".into(), Value::Bool(true));
947 }
948 fields.insert(name.clone(), Value::Mapping(entry));
949 }
950 map.insert("fields".into(), Value::Mapping(fields));
951 }
952
953 if !self.views.is_empty() {
954 let mut views = Mapping::new();
955 for spec in &self.views {
956 views.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
957 }
958 map.insert(prov_views::VIEWS_KEY.into(), Value::Mapping(views));
959 }
960
961 if !self.exports.is_empty() {
962 let mut exports = Mapping::new();
963 for spec in &self.exports {
964 exports.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
965 }
966 map.insert(prov_exports::EXPORTS_KEY.into(), Value::Mapping(exports));
967 }
968
969 map.insert(
970 "id_storage".into(),
971 Value::String(self.id_storage.as_config_str().into()),
972 );
973 map.insert("updated".into(), Value::String(self.updated.clone()));
974 map.insert(
975 "identity".into(),
976 Value::String(registration_str(self.identity).into()),
977 );
978 map.insert(
979 "fixity".into(),
980 Value::String(self.fixity.as_config_str().into()),
981 );
982 map.insert("recycle_bin".into(), Value::Bool(self.recycle_bin));
983 map.insert(
984 "about".into(),
985 Value::String(self.about.as_config_str().into()),
986 );
987 map.insert(
988 "workspace_id".into(),
989 Value::String(self.workspace_id.clone()),
990 );
991 map
992 }
993}
994
995#[derive(Debug, Clone, PartialEq, Eq)]
1003pub struct ConfigIssue {
1004 pub key: String,
1006 pub kind: ConfigIssueKind,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1012pub enum ConfigIssueKind {
1013 UnknownKey { suggestion: String },
1019 InvalidValue {
1023 value: String,
1024 expected: Vec<String>,
1025 },
1026 SpanningNotSingleParent { inverse: String },
1031 NestNotSingleValued { field: String },
1039 MalformedWorkspaceId { value: String },
1051}
1052
1053const TOP_KEYS: &[&str] = &[
1055 "spec",
1056 "content_format",
1057 "metadata",
1058 "references",
1059 "relations",
1060 "spanning",
1061 "fields",
1062 "views",
1063 "exports",
1064 "id_storage",
1065 "updated",
1066 "workspace_id",
1067 "identity",
1068 "fixity",
1069 "recycle_bin",
1070 "about",
1071];
1072const METADATA_KEYS: &[&str] = &["format", "embed"];
1074const REFERENCE_KEYS: &[&str] = &["notation", "path_style", "target", "label"];
1077const RELATION_DEF_KEYS: &[&str] = &["cardinality", "inverse", "means"];
1080const FIELD_KEYS: &[&str] = &["type", "values", "vocabulary", "reify"];
1082
1083pub fn spec_ahead(meta: &Value) -> Option<i64> {
1090 match meta.get("spec") {
1091 Some(Value::Int(v)) if *v > SPEC_VERSION => Some(*v),
1092 _ => None,
1093 }
1094}
1095
1096pub fn diagnose(meta: &Value) -> Vec<ConfigIssue> {
1102 let mut issues = Vec::new();
1103 let Some(map) = meta.as_mapping() else {
1104 return issues;
1105 };
1106 for (key, value) in map {
1107 match key.as_str() {
1108 "spec" => {} "content_format" => {
1110 enum_axis(
1111 &mut issues,
1112 key,
1113 value,
1114 |s| ContentFormat::from_config_str(s).is_some(),
1115 &["markdown", "djot", "html"],
1116 );
1117 }
1118 "id_storage" => {
1119 enum_axis(
1120 &mut issues,
1121 key,
1122 value,
1123 |s| IdStorage::from_config_str(s).is_some(),
1124 &["registry", "frontmatter", "both"],
1125 );
1126 }
1127 "identity" => {
1128 enum_axis(
1129 &mut issues,
1130 key,
1131 value,
1132 |s| registration_from_str(s).is_some(),
1133 &["none", "lazy", "eager"],
1134 );
1135 }
1136 "fixity" => {
1137 enum_axis(
1138 &mut issues,
1139 key,
1140 value,
1141 |s| Fixity::from_config_str(s).is_some(),
1142 &["off", "attachments", "all"],
1143 );
1144 }
1145 "recycle_bin" => bool_axis(&mut issues, key, value),
1146 "about" => {
1147 enum_axis(
1148 &mut issues,
1149 key,
1150 value,
1151 |s| About::from_config_str(s).is_some(),
1152 &["off", "structure"],
1153 );
1154 }
1155 "updated" => {} "workspace_id" => {
1166 let ok = match value.as_str() {
1167 Some(s) => s.is_empty() || is_valid_workspace_id(s),
1168 None => false,
1169 };
1170 if !ok {
1171 issues.push(ConfigIssue {
1172 key: key.clone(),
1173 kind: ConfigIssueKind::MalformedWorkspaceId {
1174 value: value_summary(value),
1175 },
1176 });
1177 }
1178 }
1179 "spanning" => {
1180 if value.as_str().is_none() {
1183 issues.push(ConfigIssue {
1184 key: key.clone(),
1185 kind: ConfigIssueKind::InvalidValue {
1186 value: value_summary(value),
1187 expected: vec!["a relation name".into()],
1188 },
1189 });
1190 }
1191 }
1192 "metadata" => diagnose_metadata(&mut issues, value),
1193 "references" => diagnose_reference_block(&mut issues, "references", value),
1194 "relations" => diagnose_relations(&mut issues, value),
1195 "fields" => diagnose_fields(&mut issues, value),
1196 "views" => diagnose_views(&mut issues, value, map),
1197 "exports" => diagnose_exports(&mut issues, value, map),
1198 other => {
1199 if let Some(suggestion) = nearest(other, TOP_KEYS) {
1200 issues.push(unknown(key.clone(), suggestion));
1201 }
1202 }
1203 }
1204 }
1205 diagnose_spanning_invariant(&mut issues, map);
1206 issues
1207}
1208
1209fn diagnose_spanning_invariant(issues: &mut Vec<ConfigIssue>, map: &Mapping) {
1217 let Some(spanning) = map.get("spanning").and_then(Value::as_str) else {
1218 return;
1219 };
1220 let Some(relations) = map.get("relations").and_then(Value::as_mapping) else {
1221 return;
1222 };
1223 let Some(inverse) = relations
1224 .get(spanning)
1225 .and_then(Value::as_mapping)
1226 .and_then(|r| r.get("inverse"))
1227 .and_then(Value::as_str)
1228 else {
1229 return;
1230 };
1231 let inverse_cardinality = relations
1232 .get(inverse)
1233 .and_then(Value::as_mapping)
1234 .and_then(|r| r.get("cardinality"))
1235 .and_then(Value::as_str);
1236 if inverse_cardinality == Some("many") {
1237 issues.push(ConfigIssue {
1238 key: "spanning".into(),
1239 kind: ConfigIssueKind::SpanningNotSingleParent {
1240 inverse: inverse.to_string(),
1241 },
1242 });
1243 }
1244}
1245
1246fn diagnose_metadata(issues: &mut Vec<ConfigIssue>, value: &Value) {
1248 let Some(map) = value.as_mapping() else {
1249 return block_shape_issue(issues, "metadata", value);
1250 };
1251 for (key, v) in map {
1252 let dotted = format!("metadata.{key}");
1253 match key.as_str() {
1254 "format" => enum_axis(
1255 issues,
1256 &dotted,
1257 v,
1258 |s| format_from_str(s).is_some(),
1259 &embed_format_spellings(),
1260 ),
1261 "embed" => enum_axis(
1262 issues,
1263 &dotted,
1264 v,
1265 |s| EmbedStyle::from_config_str(s).is_some(),
1266 &[
1267 "delimited",
1268 "code_block",
1269 "html_script",
1270 "html_code",
1271 "separate",
1272 ],
1273 ),
1274 other => {
1275 if let Some(sug) = nearest(other, METADATA_KEYS) {
1276 issues.push(unknown(dotted, format!("metadata.{sug}")));
1277 }
1278 }
1279 }
1280 }
1281}
1282
1283fn diagnose_reference_block(issues: &mut Vec<ConfigIssue>, prefix: &str, value: &Value) {
1286 let Some(map) = value.as_mapping() else {
1287 return block_shape_issue(issues, prefix, value);
1288 };
1289 for (key, v) in map {
1290 let dotted = format!("{prefix}.{key}");
1291 match key.as_str() {
1292 "notation" => enum_axis(
1293 issues,
1294 &dotted,
1295 v,
1296 |s| Notation::from_config_str(s).is_some(),
1297 &["markdown", "wikilink", "bare"],
1298 ),
1299 "path_style" => enum_axis(
1300 issues,
1301 &dotted,
1302 v,
1303 |s| PathStyle::from_config_str(s).is_some(),
1304 &["root", "relative"],
1305 ),
1306 "target" => enum_axis(
1307 issues,
1308 &dotted,
1309 v,
1310 |s| Addressing::from_config_str(s).is_some(),
1311 &["path", "id", "alias"],
1312 ),
1313 "label" => bool_axis(issues, &dotted, v),
1314 other => {
1315 if let Some(sug) = nearest(other, REFERENCE_KEYS) {
1316 issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1317 }
1318 }
1319 }
1320 }
1321}
1322
1323fn diagnose_relations(issues: &mut Vec<ConfigIssue>, value: &Value) {
1326 let Some(map) = value.as_mapping() else {
1327 return block_shape_issue(issues, "relations", value);
1328 };
1329 for (name, spec) in map {
1330 diagnose_relation_entry(issues, name, spec);
1331 }
1332}
1333
1334fn diagnose_relation_entry(issues: &mut Vec<ConfigIssue>, name: &str, value: &Value) {
1340 let prefix = format!("relations.{name}");
1341 let Some(map) = value.as_mapping() else {
1342 return block_shape_issue(issues, &prefix, value);
1343 };
1344 for (key, v) in map {
1345 let dotted = format!("{prefix}.{key}");
1346 match key.as_str() {
1347 "notation" => enum_axis(
1348 issues,
1349 &dotted,
1350 v,
1351 |s| Notation::from_config_str(s).is_some(),
1352 &["markdown", "wikilink", "bare"],
1353 ),
1354 "path_style" => enum_axis(
1355 issues,
1356 &dotted,
1357 v,
1358 |s| PathStyle::from_config_str(s).is_some(),
1359 &["root", "relative"],
1360 ),
1361 "target" => enum_axis(
1362 issues,
1363 &dotted,
1364 v,
1365 |s| Addressing::from_config_str(s).is_some(),
1366 &["path", "id", "alias"],
1367 ),
1368 "label" => bool_axis(issues, &dotted, v),
1369 "cardinality" => enum_axis(
1370 issues,
1371 &dotted,
1372 v,
1373 |s| cardinality_from_str(s).is_some(),
1374 &["one", "many"],
1375 ),
1376 "inverse" => {
1377 if v.as_str().is_none() {
1378 issues.push(ConfigIssue {
1379 key: dotted,
1380 kind: ConfigIssueKind::InvalidValue {
1381 value: value_summary(v),
1382 expected: vec!["a relation name".into()],
1383 },
1384 });
1385 }
1386 }
1387 "means" => {} other => {
1389 let mut valid: Vec<&str> = REFERENCE_KEYS.to_vec();
1390 valid.extend_from_slice(RELATION_DEF_KEYS);
1391 if let Some(sug) = nearest(other, &valid) {
1392 issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1393 }
1394 }
1395 }
1396 }
1397}
1398
1399fn diagnose_fields(issues: &mut Vec<ConfigIssue>, value: &Value) {
1402 let Some(map) = value.as_mapping() else {
1403 return block_shape_issue(issues, "fields", value);
1404 };
1405 for (name, spec) in map {
1406 let prefix = format!("fields.{name}");
1407 let Some(entry) = spec.as_mapping() else {
1408 block_shape_issue(issues, &prefix, spec);
1409 continue;
1410 };
1411 for (key, v) in entry {
1412 let dotted = format!("{prefix}.{key}");
1413 match key.as_str() {
1414 "type" => enum_axis(
1415 issues,
1416 &dotted,
1417 v,
1418 |s| field_type_from_config_str(s).is_some(),
1419 FIELD_TYPES,
1420 ),
1421 "values" => enum_axis(
1422 issues,
1423 &dotted,
1424 v,
1425 |s| OpenClosed::from_config_str(s).is_some(),
1426 &["open", "closed"],
1427 ),
1428 "vocabulary" => {
1429 if v.as_str().is_none() {
1430 issues.push(ConfigIssue {
1431 key: dotted,
1432 kind: ConfigIssueKind::InvalidValue {
1433 value: value_summary(v),
1434 expected: vec!["a link to a vocabulary document".into()],
1435 },
1436 });
1437 }
1438 }
1439 "reify" => bool_axis(issues, &dotted, v),
1440 other => {
1441 if let Some(sug) = nearest(other, FIELD_KEYS) {
1442 issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1443 }
1444 }
1445 }
1446 }
1447 }
1448}
1449
1450fn diagnose_views(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
1457 let Some(map) = value.as_mapping() else {
1458 return block_shape_issue(issues, "views", value);
1459 };
1460 for (name, spec) in map {
1461 let prefix = format!("views.{name}");
1462 diagnose_nest_is_fileable(issues, &prefix, spec, surface);
1463 for issue in prov_views::diagnose_view(name, spec) {
1464 let dotted = match issue.key.as_str() {
1465 "" => prefix.clone(),
1466 key => format!("{prefix}.{key}"),
1467 };
1468 let expected = || issue.kind.expected().iter().map(|s| (*s).into()).collect();
1469 match &issue.kind {
1470 ViewIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
1471 ViewIssueKind::NoGrouping => issues.push(ConfigIssue {
1472 key: dotted,
1473 kind: ConfigIssueKind::InvalidValue {
1474 value: spec
1475 .get("group")
1476 .map_or_else(|| "(absent)".to_string(), value_summary),
1477 expected: vec![
1478 "a field name, or a list of field names to try in order".into(),
1479 ],
1480 },
1481 }),
1482 ViewIssueKind::BadGrain => issues.push(ConfigIssue {
1483 key: dotted.clone(),
1484 kind: ConfigIssueKind::InvalidValue {
1485 value: spec
1486 .get(&issue.key)
1487 .map_or_else(|| "(absent)".to_string(), value_summary),
1488 expected: expected(),
1489 },
1490 }),
1491 ViewIssueKind::NoCondition => issues.push(ConfigIssue {
1492 key: dotted,
1493 kind: ConfigIssueKind::InvalidValue {
1494 value: spec
1495 .get("where")
1496 .map_or_else(|| "(absent)".to_string(), value_summary),
1497 expected: expected(),
1498 },
1499 }),
1500 ViewIssueKind::UnknownKey => {
1505 if let Some(sug) = nearest(&issue.key, prov_views::VIEW_KEYS) {
1506 issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1507 }
1508 }
1509 }
1510 }
1511 }
1512}
1513
1514fn diagnose_nest_is_fileable(
1531 issues: &mut Vec<ConfigIssue>,
1532 prefix: &str,
1533 spec: &Value,
1534 surface: &Mapping,
1535) {
1536 if spec.get("nest").is_none() {
1537 return;
1538 }
1539 let Some(fields) = surface.get("fields").and_then(Value::as_mapping) else {
1540 return;
1541 };
1542 let Some(view) = prov_views::ViewSpec::parse("", spec) else {
1543 return;
1544 };
1545 let multi: Vec<&String> = view
1548 .group
1549 .keys
1550 .iter()
1551 .filter(|key| {
1552 fields
1553 .get(*key)
1554 .and_then(|f| f.get("type"))
1555 .and_then(Value::as_str)
1556 .and_then(field_type_from_config_str)
1557 == Some(FieldType::Seq)
1558 })
1559 .collect();
1560 if let Some(field) = multi.first() {
1561 issues.push(ConfigIssue {
1562 key: format!("{prefix}.nest"),
1563 kind: ConfigIssueKind::NestNotSingleValued {
1564 field: (*field).clone(),
1565 },
1566 });
1567 }
1568}
1569
1570fn diagnose_exports(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
1580 let Some(map) = value.as_mapping() else {
1581 return block_shape_issue(issues, "exports", value);
1582 };
1583 for (name, spec) in map {
1584 let prefix = format!("exports.{name}");
1585 diagnose_export_view_is_declared(issues, &prefix, spec, surface);
1586 for issue in prov_exports::diagnose_export(name, spec) {
1587 match &issue.kind {
1588 ExportIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
1589 ExportIssueKind::NoGate => issues.push(ConfigIssue {
1590 key: format!("{prefix}.gate"),
1591 kind: ConfigIssueKind::InvalidValue {
1592 value: spec
1593 .get("gate")
1594 .map_or_else(|| "(absent)".to_string(), value_summary),
1595 expected: vec![
1596 "a mapping with `field` and `value` — the field a document \
1597 declares its membership in, and the value that admits it"
1598 .into(),
1599 ],
1600 },
1601 }),
1602 ExportIssueKind::UnknownKey => {
1606 if let Some(sug) = nearest(&issue.key, prov_exports::EXPORT_KEYS) {
1607 issues.push(unknown(
1608 format!("{prefix}.{}", issue.key),
1609 format!("{prefix}.{sug}"),
1610 ));
1611 }
1612 }
1613 ExportIssueKind::GateUnknownKey => {
1614 if let Some(sug) = nearest(&issue.key, prov_exports::GATE_KEYS) {
1615 issues.push(unknown(
1616 format!("{prefix}.gate.{}", issue.key),
1617 format!("{prefix}.gate.{sug}"),
1618 ));
1619 }
1620 }
1621 }
1622 }
1623 }
1624}
1625
1626fn diagnose_export_view_is_declared(
1638 issues: &mut Vec<ConfigIssue>,
1639 prefix: &str,
1640 spec: &Value,
1641 surface: &Mapping,
1642) {
1643 let Some(named) = spec.get("view").and_then(Value::as_str).map(str::trim) else {
1644 return;
1645 };
1646 let Some(views) = surface
1647 .get(prov_views::VIEWS_KEY)
1648 .and_then(Value::as_mapping)
1649 else {
1650 return;
1651 };
1652 if named.is_empty() || views.contains_key(named) {
1653 return;
1654 }
1655 let declared: Vec<String> = views.keys().cloned().collect();
1656 issues.push(ConfigIssue {
1657 key: format!("{prefix}.view"),
1658 kind: ConfigIssueKind::InvalidValue {
1659 value: named.to_string(),
1660 expected: declared,
1661 },
1662 });
1663}
1664
1665fn block_shape_issue(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
1667 issues.push(ConfigIssue {
1668 key: key.to_string(),
1669 kind: ConfigIssueKind::InvalidValue {
1670 value: value_summary(value),
1671 expected: vec!["a block of keys".into()],
1672 },
1673 });
1674}
1675
1676fn enum_axis(
1679 issues: &mut Vec<ConfigIssue>,
1680 key: &str,
1681 value: &Value,
1682 parses: impl Fn(&str) -> bool,
1683 expected: &[&str],
1684) {
1685 if !value.as_str().is_some_and(parses) {
1686 issues.push(ConfigIssue {
1687 key: key.to_string(),
1688 kind: ConfigIssueKind::InvalidValue {
1689 value: value_summary(value),
1690 expected: expected.iter().map(|s| s.to_string()).collect(),
1691 },
1692 });
1693 }
1694}
1695
1696fn bool_axis(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
1698 if value.as_bool().is_none() {
1699 issues.push(ConfigIssue {
1700 key: key.to_string(),
1701 kind: ConfigIssueKind::InvalidValue {
1702 value: value_summary(value),
1703 expected: vec!["true".into(), "false".into()],
1704 },
1705 });
1706 }
1707}
1708
1709fn unknown(key: String, suggestion: String) -> ConfigIssue {
1710 ConfigIssue {
1711 key,
1712 kind: ConfigIssueKind::UnknownKey { suggestion },
1713 }
1714}
1715
1716fn embed_format_spellings() -> Vec<&'static str> {
1719 #[allow(unused_mut)]
1721 let mut v = vec!["yaml"];
1722 #[cfg(feature = "json")]
1723 v.push("json");
1724 #[cfg(feature = "toml")]
1725 v.push("toml");
1726 #[cfg(feature = "fig-lang")]
1727 v.push("fig");
1728 v
1729}
1730
1731fn value_summary(value: &Value) -> String {
1733 match value {
1734 Value::String(s) => s.clone(),
1735 Value::Bool(b) => b.to_string(),
1736 Value::Int(i) => i.to_string(),
1737 Value::Float(f) => f.to_string(),
1738 _ => "(non-scalar)".to_string(),
1739 }
1740}
1741
1742pub fn metadata_format_from_str(value: &str) -> Option<fig::Format> {
1747 format_from_str(value)
1748}
1749
1750pub fn metadata_format_str(format: fig::Format) -> &'static str {
1753 format_str(format)
1754}
1755
1756fn format_from_str(value: &str) -> Option<fig::Format> {
1759 match value {
1760 "yaml" | "yml" => Some(fig::Format::Yaml),
1761 #[cfg(feature = "json")]
1762 "json" => Some(fig::Format::Json),
1763 #[cfg(feature = "toml")]
1764 "toml" => Some(fig::Format::Toml),
1765 #[cfg(feature = "fig-lang")]
1766 "fig" => Some(fig::Format::Fig),
1767 _ => None,
1768 }
1769}
1770
1771fn format_str(format: fig::Format) -> &'static str {
1773 match format {
1774 #[cfg(feature = "json")]
1775 fig::Format::Json => "json",
1776 #[cfg(feature = "toml")]
1777 fig::Format::Toml => "toml",
1778 #[cfg(feature = "fig-lang")]
1779 fig::Format::Fig => "fig",
1780 _ => "yaml",
1781 }
1782}
1783
1784fn cardinality_from_str(value: &str) -> Option<Cardinality> {
1786 match value {
1787 "one" => Some(Cardinality::One),
1788 "many" => Some(Cardinality::Many),
1789 _ => None,
1790 }
1791}
1792
1793fn cardinality_str(cardinality: Cardinality) -> &'static str {
1795 match cardinality {
1796 Cardinality::One => "one",
1797 Cardinality::Many => "many",
1798 }
1799}
1800
1801fn registration_from_str(value: &str) -> Option<Registration> {
1807 match value {
1808 "none" | "off" => Some(Registration::OFF),
1809 "lazy" => Some(Registration::LAZY),
1810 "eager" => Some(Registration::EAGER),
1811 _ => None,
1812 }
1813}
1814
1815fn registration_str(registration: Registration) -> &'static str {
1818 match registration {
1819 Registration::OFF => "none",
1820 Registration::EAGER => "eager",
1821 _ => "lazy",
1822 }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828 use prov_graph::identity::Trigger;
1829
1830 fn config_doc(pairs: &[(&str, &str)]) -> Value {
1833 let mut map = Mapping::new();
1834 for (k, v) in pairs {
1835 let value = match *v {
1836 "true" => Value::Bool(true),
1837 "false" => Value::Bool(false),
1838 other => Value::String(other.into()),
1839 };
1840 map.insert((*k).into(), value);
1841 }
1842 Value::Mapping(map)
1843 }
1844
1845 #[test]
1847 #[cfg(feature = "yaml")]
1848 fn relation_set_builds_a_custom_vocabulary_and_falls_back_to_diaryx() {
1849 use prov_graph::document::Document;
1850
1851 fn doc(text: &str) -> Document {
1852 Document::parse("index.md", text).unwrap()
1853 }
1854
1855 let default_set = WorkspaceConfig::default().relation_set();
1857 assert_eq!(default_set.spanning_relation(), Some("contents"));
1858 assert_eq!(default_set.registry_relation(), Some("registry"));
1859
1860 let config = WorkspaceConfig {
1863 spanning: Some("part".into()),
1864 relation_defs: BTreeMap::from([
1865 (
1866 "part".to_string(),
1867 RelationDef {
1868 cardinality: Some(Cardinality::Many),
1869 inverse: Some("whole".to_string()),
1870 means: None,
1871 },
1872 ),
1873 (
1874 "whole".to_string(),
1875 RelationDef {
1876 cardinality: Some(Cardinality::One),
1877 inverse: Some("part".to_string()),
1878 means: None,
1879 },
1880 ),
1881 ]),
1882 ..WorkspaceConfig::default()
1883 };
1884 let set = config.relation_set();
1885 assert_eq!(set.spanning_relation(), Some("part"));
1886 let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
1887 assert_eq!(
1888 set.children(&fig::Value::from(&d.meta)),
1889 vec!["one.md".to_string(), "two.md".to_string()]
1890 );
1891 assert_eq!(set.registry_relation(), Some("registry"));
1894 assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
1895 assert_eq!(set.history_relation(), Some("history"));
1896 assert!(set.relations().iter().any(|r| r.name == "history"));
1897 assert_eq!(set.about_relation(), Some("about"));
1898 assert!(set.relations().iter().any(|r| r.name == "about"));
1899 }
1900
1901 #[test]
1902 fn presets_encode_the_two_styles() {
1903 assert_eq!(WorkspaceConfig::paths_only().identity, Registration::OFF);
1905 assert_eq!(
1906 WorkspaceConfig::paths_only().reference_target,
1907 Addressing::Path
1908 );
1909 assert!(
1910 WorkspaceConfig::stable_ids()
1911 .identity
1912 .fires_on(Trigger::Link)
1913 );
1914 assert_eq!(
1915 WorkspaceConfig::stable_ids().reference_target,
1916 Addressing::Id
1917 );
1918 }
1919
1920 #[test]
1921 fn round_trips_through_a_nested_mapping() {
1922 let config = WorkspaceConfig {
1923 identity: Registration::EAGER,
1924 notation: Notation::Bare,
1925 path_style: PathStyle::Relative,
1926 reference_target: Addressing::Id,
1927 reference_label: true,
1928 relation_styles: BTreeMap::from([
1929 (
1930 "contents".to_string(),
1931 RelationStyleConfig {
1932 notation: Some(Notation::Wikilink),
1933 path_style: None,
1934 target: Some(Addressing::Alias),
1935 label: None,
1936 },
1937 ),
1938 (
1939 "part_of".to_string(),
1940 RelationStyleConfig {
1941 notation: Some(Notation::Markdown),
1942 path_style: Some(PathStyle::Relative),
1943 target: Some(Addressing::Id),
1944 label: Some(false),
1945 },
1946 ),
1947 ]),
1948 spanning: Some("contents".to_string()),
1949 relation_defs: BTreeMap::from([
1950 (
1951 "contents".to_string(),
1952 RelationDef {
1953 cardinality: Some(Cardinality::Many),
1954 inverse: Some("part_of".to_string()),
1955 means: Some("documents contained by this one".to_string()),
1956 },
1957 ),
1958 (
1959 "part_of".to_string(),
1960 RelationDef {
1961 cardinality: Some(Cardinality::One),
1962 inverse: Some("contents".to_string()),
1963 means: None,
1964 },
1965 ),
1966 ]),
1967 fields: BTreeMap::from([
1968 (
1969 "audience".to_string(),
1970 FieldSpec {
1971 ty: Some(FieldType::Str),
1972 values: OpenClosed::Closed,
1973 vocabulary: Some("[Audiences](/vocab/audiences.yaml)".to_string()),
1974 reify: true,
1975 },
1976 ),
1977 (
1980 "created".to_string(),
1981 FieldSpec {
1982 ty: Some(FieldType::Extended(ExtKind::LocalDate)),
1983 values: OpenClosed::default(),
1984 vocabulary: None,
1985 reify: false,
1986 },
1987 ),
1988 ]),
1989 views: vec![
1990 ViewSpec {
1994 name: "daily".to_string(),
1995 label: Some("Daily".to_string()),
1996 icon: Some("calendar".to_string()),
1997 group: prov_views::Grouping {
1998 keys: vec!["date_of_document".to_string(), "created".to_string()],
1999 by: Some(prov_views::Grain::Month),
2000 },
2001 under: Some("[Daily](id:abc1234)".to_string()),
2002 filter: Some(prov_views::Condition::Not(Box::new(
2004 prov_views::Condition::Has("draft".to_string()),
2005 ))),
2006 nest: Some(prov_views::Grain::Year),
2007 },
2008 ViewSpec {
2011 name: "who".to_string(),
2012 label: None,
2013 icon: None,
2014 group: prov_views::Grouping::field("people"),
2015 under: None,
2016 filter: None,
2017 nest: None,
2018 },
2019 ],
2020 exports: vec![
2021 ExportSpec {
2024 name: "letters".to_string(),
2025 label: Some("Letters home".to_string()),
2026 gate: prov_exports::Gate {
2027 field: "audience".to_string(),
2028 value: "family".to_string(),
2029 },
2030 view: Some("daily".to_string()),
2031 },
2032 ExportSpec {
2033 name: "notes".to_string(),
2034 label: None,
2035 gate: prov_exports::Gate {
2036 field: "audience".to_string(),
2037 value: "public".to_string(),
2038 },
2039 view: None,
2040 },
2041 ],
2042 id_storage: IdStorage::Frontmatter,
2043 default_embed_format: fig::Format::Yaml,
2044 embed_style: EmbedStyle::CodeBlock,
2045 content_format: ContentFormat::Djot,
2046 recycle_bin: false,
2047 fixity: Fixity::Full,
2048 about: About::Off,
2053 updated: "modified".to_string(),
2054 workspace_id: "notes".to_string(),
2057 };
2058 let back = WorkspaceConfig::from_meta(&Value::Mapping(config.to_mapping()));
2059 assert_eq!(back, config);
2060 }
2061
2062 #[test]
2063 fn per_relation_styles_resolve_over_the_workspace_default() {
2064 let mut cfg = WorkspaceConfig::default();
2068 cfg.apply(&config_doc_nested(
2069 &[("target", "id")],
2070 &[
2071 ("contents", &[("notation", "wikilink"), ("target", "alias")]),
2072 ("part_of", &[("target", "id")]),
2073 ],
2074 ));
2075
2076 let styles = cfg.resolved_relation_styles();
2077 let down = styles.get("contents").expect("contents style");
2078 assert_eq!(down.wrapper, prov_graph::link::Wrapper::Wikilink);
2079 assert_eq!(down.addressing, Addressing::Alias);
2080
2081 let up = styles.get("part_of").expect("part_of style");
2082 assert_eq!(up.wrapper, prov_graph::link::Wrapper::Markdown);
2084 assert_eq!(up.addressing, Addressing::Id);
2085 }
2086
2087 fn config_doc_nested(
2090 references: &[(&str, &str)],
2091 relations: &[(&str, &[(&str, &str)])],
2092 ) -> Value {
2093 let mut top = Mapping::new();
2094 let mut refs = Mapping::new();
2095 for (k, v) in references {
2096 refs.insert((*k).into(), Value::String((*v).into()));
2097 }
2098 top.insert("references".into(), Value::Mapping(refs));
2099 let mut rels = Mapping::new();
2100 for (name, axes) in relations {
2101 let mut spec = Mapping::new();
2102 for (k, v) in *axes {
2103 spec.insert((*k).into(), Value::String((*v).into()));
2104 }
2105 rels.insert((*name).into(), Value::Mapping(spec));
2106 }
2107 top.insert("relations".into(), Value::Mapping(rels));
2108 Value::Mapping(top)
2109 }
2110
2111 #[test]
2112 fn a_retired_canonical_path_style_is_reported_and_falls_back_to_root() {
2113 let mut cfg = WorkspaceConfig::default();
2128 let mut refs = Mapping::new();
2129 refs.insert("path_style".into(), Value::String("canonical".into()));
2130 let mut top = Mapping::new();
2131 top.insert("references".into(), Value::Mapping(refs));
2132 let meta = Value::Mapping(top);
2133
2134 cfg.apply(&meta);
2135 assert_eq!(cfg.path_style, PathStyle::Root, "the resolvable spelling");
2136
2137 let issues = diagnose(&meta);
2138 assert!(
2139 issues.iter().any(|i| matches!(
2140 &i.kind,
2141 ConfigIssueKind::InvalidValue { value, expected }
2142 if value.contains("canonical") && expected == &["root", "relative"]
2143 )),
2144 "{issues:?}"
2145 );
2146 }
2147
2148 #[test]
2149 fn reference_axes_orthogonalize_notation_and_resolution() {
2150 let mut cfg = WorkspaceConfig::default();
2152 let mut refs = Mapping::new();
2153 refs.insert("notation".into(), Value::String("bare".into()));
2154 refs.insert("path_style".into(), Value::String("relative".into()));
2155 let mut top = Mapping::new();
2156 top.insert("references".into(), Value::Mapping(refs));
2157 cfg.apply(&Value::Mapping(top));
2158 assert_eq!(cfg.link_format(), LinkStyle::PlainRelative);
2159 assert_eq!(cfg.notation, Notation::Bare);
2160 assert_eq!(cfg.path_style, PathStyle::Relative);
2161 }
2162
2163 #[test]
2164 fn apply_overlays_only_present_keys_so_the_config_document_wins() {
2165 let mut config = WorkspaceConfig::default();
2166 config.apply(&config_doc(&[("content_format", "djot")]));
2168 assert_eq!(config.content_format, ContentFormat::Djot);
2169 assert_eq!(config.identity, Registration::LAZY, "identity untouched");
2170 config.apply(&config_doc(&[("identity", "none")]));
2172 assert_eq!(config.identity, Registration::OFF);
2173 assert_eq!(config.content_format, ContentFormat::Djot);
2174 }
2175
2176 #[test]
2177 fn diagnose_is_silent_on_a_clean_config_and_on_user_fields() {
2178 let doc = config_doc(&[
2179 ("title", "prov config"),
2180 ("part_of", "index.md"),
2181 ("id", "abc123"),
2182 ("spec", "1"),
2183 ("identity", "lazy"),
2184 ("fixity", "all"),
2185 ("recycle_bin", "false"),
2186 ("content_format", "djot"),
2187 ("id_storage", "both"),
2188 ("author", "someone"),
2189 ]);
2190 assert!(diagnose(&doc).is_empty(), "flagged: {:?}", diagnose(&doc));
2191 }
2192
2193 #[test]
2194 fn diagnose_flags_a_misspelled_top_level_key_with_a_suggestion() {
2195 let issues = diagnose(&config_doc(&[("recyle_bin", "false")]));
2196 assert_eq!(issues.len(), 1);
2197 assert_eq!(
2198 issues[0].kind,
2199 ConfigIssueKind::UnknownKey {
2200 suggestion: "recycle_bin".into()
2201 }
2202 );
2203 }
2204
2205 #[test]
2206 fn workspace_id_applies_when_well_formed_and_is_ignored_when_not() {
2207 let mut cfg = WorkspaceConfig::default();
2208 assert_eq!(cfg.workspace_id, "", "anonymous by default");
2209
2210 cfg.apply(&config_doc(&[("workspace_id", "notes")]));
2211 assert_eq!(cfg.workspace_id, "notes");
2212
2213 for bad in ["with/slash", "with:colon", "with space", ""] {
2216 cfg.apply(&config_doc(&[("workspace_id", bad)]));
2217 assert_eq!(cfg.workspace_id, "notes", "rejected {bad:?}");
2218 }
2219 }
2220
2221 #[test]
2222 fn diagnose_flags_a_malformed_workspace_id_but_not_an_empty_one() {
2223 for bad in ["with/slash", "with:colon", "with space"] {
2224 let issues = diagnose(&config_doc(&[("workspace_id", bad)]));
2225 assert_eq!(
2226 issues.first().map(|i| &i.kind),
2227 Some(&ConfigIssueKind::MalformedWorkspaceId {
2228 value: bad.to_string()
2229 }),
2230 "{bad:?}"
2231 );
2232 }
2233 assert!(
2236 diagnose(&config_doc(&[("workspace_id", "")])).is_empty(),
2237 "an empty name is anonymity, not an error"
2238 );
2239 assert!(diagnose(&config_doc(&[("workspace_id", "notes")])).is_empty());
2240 }
2241
2242 #[test]
2243 fn diagnose_flags_bad_values_and_typos_inside_nested_blocks() {
2244 let mut refs = Mapping::new();
2246 refs.insert("notaton".into(), Value::String("markdown".into()));
2247 refs.insert("target".into(), Value::String("pointer".into()));
2248 let mut top = Mapping::new();
2249 top.insert("references".into(), Value::Mapping(refs));
2250 let issues = diagnose(&Value::Mapping(top));
2251 assert!(
2252 issues.iter().any(|i| i.key == "references.notaton"
2253 && matches!(&i.kind, ConfigIssueKind::UnknownKey { suggestion } if suggestion == "references.notation")),
2254 "{issues:?}"
2255 );
2256 assert!(
2257 issues.iter().any(|i| i.key == "references.target"
2258 && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "pointer")),
2259 "{issues:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn diagnose_flags_an_unrecognized_value_on_a_real_key() {
2265 let issues = diagnose(&config_doc(&[("fixity", "alll")]));
2266 assert_eq!(issues.len(), 1);
2267 match &issues[0].kind {
2268 ConfigIssueKind::InvalidValue { value, expected } => {
2269 assert_eq!(value, "alll");
2270 assert!(expected.contains(&"all".to_string()), "{expected:?}");
2271 }
2272 other => panic!("expected InvalidValue, got {other:?}"),
2273 }
2274 }
2275
2276 #[test]
2277 fn about_defaults_on_and_accepts_only_its_two_spellings() {
2278 assert_eq!(WorkspaceConfig::default().about, About::Structure);
2281 assert!(About::Structure.generates());
2282 assert!(!About::Off.generates());
2283
2284 let mut cfg = WorkspaceConfig::default();
2285 cfg.apply(&config_doc(&[("about", "off")]));
2286 assert_eq!(cfg.about, About::Off);
2287
2288 let issues = diagnose(&config_doc(&[("about", "structrue")]));
2291 assert_eq!(issues.len(), 1);
2292 match &issues[0].kind {
2293 ConfigIssueKind::InvalidValue { value, expected } => {
2294 assert_eq!(value, "structrue");
2295 assert!(expected.contains(&"structure".to_string()), "{expected:?}");
2296 assert!(expected.contains(&"off".to_string()), "{expected:?}");
2297 }
2298 other => panic!("expected InvalidValue, got {other:?}"),
2299 }
2300 let mut unchanged = WorkspaceConfig::default();
2301 unchanged.apply(&config_doc(&[("about", "structrue")]));
2302 assert_eq!(unchanged.about, About::Structure);
2303 }
2304
2305 #[test]
2306 fn relation_defs_and_spanning_apply_and_round_trip() {
2307 let mut top = Mapping::new();
2309 top.insert("spanning".into(), Value::String("part".into()));
2310 let mut rels = Mapping::new();
2311 let mut part = Mapping::new();
2312 part.insert("cardinality".into(), Value::String("many".into()));
2313 part.insert("inverse".into(), Value::String("whole".into()));
2314 part.insert("means".into(), Value::String("the pieces".into()));
2315 let mut whole = Mapping::new();
2316 whole.insert("cardinality".into(), Value::String("one".into()));
2317 whole.insert("inverse".into(), Value::String("part".into()));
2318 rels.insert("part".into(), Value::Mapping(part));
2319 rels.insert("whole".into(), Value::Mapping(whole));
2320 top.insert("relations".into(), Value::Mapping(rels));
2321
2322 let cfg = WorkspaceConfig::from_meta(&Value::Mapping(top));
2323 assert_eq!(cfg.spanning.as_deref(), Some("part"));
2324 let part_def = cfg.relation_defs.get("part").expect("part def");
2325 assert_eq!(part_def.cardinality, Some(Cardinality::Many));
2326 assert_eq!(part_def.inverse.as_deref(), Some("whole"));
2327 assert_eq!(part_def.means.as_deref(), Some("the pieces"));
2328 assert!(diagnose(&Value::Mapping(cfg.to_mapping())).is_empty());
2330 }
2331
2332 #[test]
2333 fn diagnose_flags_a_spanning_relation_whose_inverse_is_many() {
2334 let mut top = Mapping::new();
2337 top.insert("spanning".into(), Value::String("part".into()));
2338 let mut rels = Mapping::new();
2339 let mut part = Mapping::new();
2340 part.insert("inverse".into(), Value::String("whole".into()));
2341 let mut whole = Mapping::new();
2342 whole.insert("cardinality".into(), Value::String("many".into()));
2343 rels.insert("part".into(), Value::Mapping(part));
2344 rels.insert("whole".into(), Value::Mapping(whole));
2345 top.insert("relations".into(), Value::Mapping(rels));
2346
2347 let issues = diagnose(&Value::Mapping(top));
2348 assert!(
2349 issues.iter().any(|i| i.key == "spanning"
2350 && matches!(&i.kind, ConfigIssueKind::SpanningNotSingleParent { inverse } if inverse == "whole")),
2351 "{issues:?}"
2352 );
2353 }
2354
2355 #[test]
2358 fn a_field_may_declare_a_type_without_a_vocabulary() {
2359 let mut created = Mapping::new();
2360 created.insert("type".into(), Value::String("date".into()));
2361 let mut fields = Mapping::new();
2362 fields.insert("created".into(), Value::Mapping(created));
2363 let mut top = Mapping::new();
2364 top.insert("fields".into(), Value::Mapping(fields));
2365
2366 let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
2367 let spec = config.fields.get("created").expect("a recorded field");
2368 assert_eq!(spec.ty, Some(FieldType::Extended(ExtKind::LocalDate)));
2369 assert_eq!(spec.vocabulary, None);
2370 }
2371
2372 #[test]
2375 fn a_field_declaring_neither_type_nor_vocabulary_is_not_recorded() {
2376 let mut empty = Mapping::new();
2377 empty.insert("reify".into(), Value::Bool(true));
2378 let mut fields = Mapping::new();
2379 fields.insert("mystery".into(), Value::Mapping(empty));
2380 let mut top = Mapping::new();
2381 top.insert("fields".into(), Value::Mapping(fields));
2382
2383 let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
2384 assert!(config.fields.is_empty(), "{:?}", config.fields);
2385 }
2386
2387 fn views_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
2389 let mut views = Mapping::new();
2390 for (name, keys) in entries {
2391 let mut entry = Mapping::new();
2392 for (k, v) in *keys {
2393 entry.insert((*k).into(), v.clone());
2394 }
2395 views.insert((*name).into(), Value::Mapping(entry));
2396 }
2397 let mut top = Mapping::new();
2398 top.insert("views".into(), Value::Mapping(views));
2399 Value::Mapping(top)
2400 }
2401
2402 fn str_value(text: &str) -> Value {
2403 Value::String(text.to_string())
2404 }
2405
2406 #[test]
2407 fn views_apply_in_declaration_order() {
2408 let config = WorkspaceConfig::from_meta(&views_block(&[
2409 ("daily", &[("group", str_value("created"))]),
2410 ("who", &[("group", str_value("people"))]),
2411 ]));
2412 assert_eq!(
2413 config
2414 .views
2415 .iter()
2416 .map(|v| v.name.as_str())
2417 .collect::<Vec<_>>(),
2418 ["daily", "who"]
2419 );
2420 }
2421
2422 #[test]
2428 fn a_later_surface_replaces_one_view_and_leaves_the_others() {
2429 let mut config = WorkspaceConfig::from_meta(&views_block(&[
2430 (
2431 "daily",
2432 &[
2433 ("group", str_value("created")),
2434 ("by", str_value("month")),
2435 ("icon", str_value("calendar")),
2436 ],
2437 ),
2438 ("who", &[("group", str_value("people"))]),
2439 ]));
2440 config.apply(&views_block(&[(
2441 "daily",
2442 &[("group", str_value("date_of_document"))],
2443 )]));
2444
2445 assert_eq!(
2446 config
2447 .views
2448 .iter()
2449 .map(|v| v.name.as_str())
2450 .collect::<Vec<_>>(),
2451 ["daily", "who"],
2452 "position is kept, and the untouched view survives"
2453 );
2454 let daily = &config.views[0];
2455 assert_eq!(daily.group, prov_views::Grouping::field("date_of_document"));
2456 assert_eq!(daily.group.by, None, "replaced whole, not merged key-wise");
2457 assert_eq!(daily.icon, None);
2458 }
2459
2460 #[test]
2463 fn a_view_without_a_grouping_is_not_recorded_and_is_diagnosed() {
2464 let meta = views_block(&[("daily", &[("label", str_value("Daily"))])]);
2465 assert!(WorkspaceConfig::from_meta(&meta).views.is_empty());
2466
2467 let issues = diagnose(&meta);
2468 assert_eq!(issues.len(), 1, "{issues:?}");
2469 assert_eq!(issues[0].key, "views.daily.group");
2470 assert!(matches!(
2471 &issues[0].kind,
2472 ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
2473 ));
2474 }
2475
2476 #[test]
2480 fn diagnose_flags_a_misspelled_grain_and_a_misspelled_view_key() {
2481 let issues = diagnose(&views_block(&[(
2482 "daily",
2483 &[
2484 ("group", str_value("created")),
2485 ("by", str_value("yearr")),
2486 ("labl", str_value("Daily")),
2487 ],
2488 )]));
2489 assert!(
2490 issues.iter().any(|i| i.key == "views.daily.by"
2491 && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, expected }
2492 if value == "yearr" && expected.iter().any(|e| e == "year"))),
2493 "{issues:?}"
2494 );
2495 assert!(
2496 issues.iter().any(|i| i.key == "views.daily.labl"
2497 && i.kind
2498 == ConfigIssueKind::UnknownKey {
2499 suggestion: "views.daily.label".into()
2500 }),
2501 "{issues:?}"
2502 );
2503 }
2504
2505 fn exports_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
2507 let mut exports = Mapping::new();
2508 for (name, keys) in entries {
2509 let mut entry = Mapping::new();
2510 for (k, v) in *keys {
2511 entry.insert((*k).into(), v.clone());
2512 }
2513 exports.insert((*name).into(), Value::Mapping(entry));
2514 }
2515 let mut top = Mapping::new();
2516 top.insert("exports".into(), Value::Mapping(exports));
2517 Value::Mapping(top)
2518 }
2519
2520 fn gate_value(field: &str, value: &str) -> Value {
2521 let mut gate = Mapping::new();
2522 gate.insert("field".into(), str_value(field));
2523 gate.insert("value".into(), str_value(value));
2524 Value::Mapping(gate)
2525 }
2526
2527 #[test]
2528 fn exports_apply_and_round_trip() {
2529 let config = WorkspaceConfig::from_meta(&exports_block(&[
2530 (
2531 "letters",
2532 &[
2533 ("gate", gate_value("audience", "family")),
2534 ("view", str_value("daily")),
2535 ],
2536 ),
2537 ("notes", &[("gate", gate_value("audience", "public"))]),
2538 ]));
2539 assert_eq!(
2540 config
2541 .exports
2542 .iter()
2543 .map(|e| e.name.as_str())
2544 .collect::<Vec<_>>(),
2545 ["letters", "notes"]
2546 );
2547 assert_eq!(config.exports[0].gate.field, "audience");
2548 assert_eq!(config.exports[0].view.as_deref(), Some("daily"));
2549
2550 let written = config.to_mapping();
2551 let reread = WorkspaceConfig::from_meta(&Value::Mapping(written));
2552 assert_eq!(reread.exports, config.exports);
2553 }
2554
2555 #[test]
2559 fn a_later_surface_replaces_one_export_whole() {
2560 let mut config = WorkspaceConfig::from_meta(&exports_block(&[(
2561 "letters",
2562 &[
2563 ("gate", gate_value("audience", "family")),
2564 ("view", str_value("daily")),
2565 ],
2566 )]));
2567 config.apply(&exports_block(&[(
2568 "letters",
2569 &[("gate", gate_value("audience", "friends"))],
2570 )]));
2571
2572 assert_eq!(config.exports.len(), 1);
2573 assert_eq!(config.exports[0].gate.value, "friends");
2574 assert_eq!(
2575 config.exports[0].view, None,
2576 "replaced whole, not merged key-wise"
2577 );
2578 }
2579
2580 #[test]
2583 fn an_export_without_a_gate_is_not_recorded_and_is_diagnosed() {
2584 let meta = exports_block(&[("letters", &[("view", str_value("daily"))])]);
2585 assert!(WorkspaceConfig::from_meta(&meta).exports.is_empty());
2586
2587 let issues = diagnose(&meta);
2588 assert_eq!(issues.len(), 1, "{issues:?}");
2589 assert_eq!(issues[0].key, "exports.letters.gate");
2590 assert!(matches!(
2591 &issues[0].kind,
2592 ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
2593 ));
2594 }
2595
2596 #[test]
2597 fn diagnose_flags_misspelled_export_keys_at_both_levels() {
2598 let mut gate = Mapping::new();
2599 gate.insert("field".into(), str_value("audience"));
2600 gate.insert("valeu".into(), str_value("family"));
2601 let issues = diagnose(&exports_block(&[(
2602 "letters",
2603 &[("gate", Value::Mapping(gate)), ("veiw", str_value("daily"))],
2604 )]));
2605 assert!(
2606 issues.iter().any(|i| i.kind
2607 == ConfigIssueKind::UnknownKey {
2608 suggestion: "exports.letters.view".into()
2609 }),
2610 "{issues:?}"
2611 );
2612 assert!(
2613 issues.iter().any(|i| i.kind
2614 == ConfigIssueKind::UnknownKey {
2615 suggestion: "exports.letters.gate.value".into()
2616 }),
2617 "{issues:?}"
2618 );
2619 }
2620
2621 #[test]
2625 fn diagnose_flags_an_export_arranged_by_an_undeclared_view() {
2626 let mut top = Mapping::new();
2627 let Value::Mapping(views) = views_block(&[("daily", &[("group", str_value("created"))])])
2628 else {
2629 unreachable!()
2630 };
2631 let Value::Mapping(exports) = exports_block(&[(
2632 "letters",
2633 &[
2634 ("gate", gate_value("audience", "family")),
2635 ("view", str_value("dialy")),
2636 ],
2637 )]) else {
2638 unreachable!()
2639 };
2640 for (k, v) in views.iter().chain(exports.iter()) {
2641 top.insert(k.clone(), v.clone());
2642 }
2643
2644 let issues = diagnose(&Value::Mapping(top));
2645 assert_eq!(issues.len(), 1, "{issues:?}");
2646 assert_eq!(issues[0].key, "exports.letters.view");
2647 assert!(
2648 matches!(
2649 &issues[0].kind,
2650 ConfigIssueKind::InvalidValue { value, expected }
2651 if value == "dialy" && expected == &vec!["daily".to_string()]
2652 ),
2653 "{issues:?}"
2654 );
2655
2656 let issues = diagnose(&exports_block(&[(
2659 "letters",
2660 &[
2661 ("gate", gate_value("audience", "family")),
2662 ("view", str_value("dialy")),
2663 ],
2664 )]));
2665 assert!(issues.is_empty(), "{issues:?}");
2666 }
2667
2668 #[test]
2672 fn diagnose_flags_a_nest_on_a_multi_valued_field() {
2673 let block = |view: &[(&str, Value)]| {
2674 let mut fields = Mapping::new();
2675 let mut people = Mapping::new();
2676 people.insert("type".into(), str_value("seq"));
2677 fields.insert("people".into(), Value::Mapping(people));
2678
2679 let mut views = Mapping::new();
2680 let mut entry = Mapping::new();
2681 for (k, v) in view {
2682 entry.insert((*k).into(), v.clone());
2683 }
2684 views.insert("who".into(), Value::Mapping(entry));
2685
2686 let mut top = Mapping::new();
2687 top.insert("fields".into(), Value::Mapping(fields));
2688 top.insert("views".into(), Value::Mapping(views));
2689 Value::Mapping(top)
2690 };
2691
2692 let issues = diagnose(&block(&[
2693 ("group", str_value("people")),
2694 ("nest", str_value("initial")),
2695 ]));
2696 assert_eq!(issues.len(), 1, "{issues:?}");
2697 assert_eq!(issues[0].key, "views.who.nest");
2698 assert_eq!(
2699 issues[0].kind,
2700 ConfigIssueKind::NestNotSingleValued {
2701 field: "people".into()
2702 }
2703 );
2704
2705 assert!(
2708 diagnose(&block(&[("group", str_value("people"))])).is_empty(),
2709 "grouping by a multi-valued field is not the problem"
2710 );
2711 }
2712
2713 #[test]
2716 fn the_nest_check_is_silent_across_two_config_surfaces() {
2717 let mut views = Mapping::new();
2718 let mut entry = Mapping::new();
2719 entry.insert("group".into(), str_value("people"));
2720 entry.insert("nest".into(), str_value("initial"));
2721 views.insert("who".into(), Value::Mapping(entry));
2722 let mut top = Mapping::new();
2723 top.insert("views".into(), Value::Mapping(views));
2724
2725 assert!(
2726 diagnose(&Value::Mapping(top)).is_empty(),
2727 "no `fields` in this surface to contradict it"
2728 );
2729 }
2730
2731 #[test]
2732 fn diagnose_flags_a_views_block_that_is_not_a_block() {
2733 let mut top = Mapping::new();
2734 top.insert("views".into(), Value::String("daily".into()));
2735 let issues = diagnose(&Value::Mapping(top));
2736 assert_eq!(issues.len(), 1);
2737 assert_eq!(issues[0].key, "views");
2738
2739 let issues = diagnose(&views_block(&[]));
2740 assert!(issues.is_empty(), "an empty block is clean: {issues:?}");
2741 }
2742
2743 #[test]
2744 fn every_field_type_spelling_round_trips() {
2745 for spelling in FIELD_TYPES {
2746 let ty = field_type_from_config_str(spelling)
2747 .unwrap_or_else(|| panic!("{spelling} is offered but does not parse"));
2748 assert_eq!(field_type_as_config_str(ty), Some(*spelling));
2749 }
2750 }
2751
2752 #[test]
2753 fn diagnose_flags_an_unknown_field_type_and_offers_the_near_miss() {
2754 let mut created = Mapping::new();
2755 created.insert("type".into(), Value::String("datetime2".into()));
2756 let mut fields = Mapping::new();
2757 fields.insert("created".into(), Value::Mapping(created));
2758 let mut top = Mapping::new();
2759 top.insert("fields".into(), Value::Mapping(fields));
2760
2761 let issues = diagnose(&Value::Mapping(top));
2762 assert!(
2763 issues.iter().any(|i| i.key == "fields.created.type"
2764 && matches!(
2765 &i.kind,
2766 ConfigIssueKind::InvalidValue { expected, .. }
2767 if expected.iter().any(|e| e == "datetime")
2768 )),
2769 "{issues:?}"
2770 );
2771 }
2772
2773 #[test]
2774 fn diagnose_flags_bad_field_and_relation_def_values() {
2775 let mut top = Mapping::new();
2777 let mut fields = Mapping::new();
2778 let mut audience = Mapping::new();
2779 audience.insert("values".into(), Value::String("secret".into())); audience.insert("vocabulary".into(), Value::String("/vocab/aud.yaml".into()));
2781 fields.insert("audience".into(), Value::Mapping(audience));
2782 top.insert("fields".into(), Value::Mapping(fields));
2783 let mut rels = Mapping::new();
2784 let mut c = Mapping::new();
2785 c.insert("cardinality".into(), Value::String("two".into())); rels.insert("contents".into(), Value::Mapping(c));
2787 top.insert("relations".into(), Value::Mapping(rels));
2788
2789 let issues = diagnose(&Value::Mapping(top));
2790 assert!(
2791 issues.iter().any(|i| i.key == "fields.audience.values"),
2792 "{issues:?}"
2793 );
2794 assert!(
2795 issues
2796 .iter()
2797 .any(|i| i.key == "relations.contents.cardinality"),
2798 "{issues:?}"
2799 );
2800 }
2801
2802 #[test]
2803 fn spec_ahead_fires_only_for_a_newer_spec() {
2804 assert_eq!(
2805 spec_ahead(&config_doc(&[("identity", "lazy")])),
2806 None,
2807 "absent spec"
2808 );
2809 let at = {
2810 let mut m = Mapping::new();
2811 m.insert("spec".into(), Value::Int(SPEC_VERSION));
2812 Value::Mapping(m)
2813 };
2814 assert_eq!(spec_ahead(&at), None, "current spec is fine");
2815 let ahead = {
2816 let mut m = Mapping::new();
2817 m.insert("spec".into(), Value::Int(SPEC_VERSION + 1));
2818 Value::Mapping(m)
2819 };
2820 assert_eq!(spec_ahead(&ahead), Some(SPEC_VERSION + 1));
2821 }
2822
2823 #[test]
2824 fn serialized_defaults_and_presets_all_pass_diagnosis() {
2825 for config in [
2826 WorkspaceConfig::default(),
2827 WorkspaceConfig::paths_only(),
2828 WorkspaceConfig::stable_ids(),
2829 ] {
2830 let serialized = Value::Mapping(config.to_mapping());
2831 assert!(
2832 diagnose(&serialized).is_empty(),
2833 "flagged itself: {:?}",
2834 diagnose(&serialized)
2835 );
2836 }
2837 }
2838}