use std::collections::BTreeMap;
use crate::content::ContentFormat;
use crate::document::EmbedStyle;
use crate::identity::Registration;
use crate::link::{Addressing, LinkStyle, Notation, PathStyle, ReferenceStyle};
use crate::meta::{Mapping, Value};
pub const SPEC_VERSION: i64 = 1;
pub const ROOT_CONFIG_KEY: &str = "prov";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RelationStyleConfig {
pub notation: Option<Notation>,
pub path_style: Option<PathStyle>,
pub target: Option<Addressing>,
pub label: Option<bool>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum IdStorage {
Registry,
#[default]
Frontmatter,
FrontmatterOnly,
}
impl IdStorage {
pub fn stamps_frontmatter(self) -> bool {
matches!(self, IdStorage::Frontmatter | IdStorage::FrontmatterOnly)
}
pub fn keeps_registry(self) -> bool {
matches!(self, IdStorage::Registry | IdStorage::Frontmatter)
}
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"registry" => Some(Self::Registry),
"both" => Some(Self::Frontmatter),
"frontmatter" => Some(Self::FrontmatterOnly),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Registry => "registry",
Self::Frontmatter => "both",
Self::FrontmatterOnly => "frontmatter",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Fixity {
Off,
#[default]
Payloads,
Full,
}
impl Fixity {
pub fn covers_payloads(self) -> bool {
matches!(self, Fixity::Payloads | Fixity::Full)
}
pub fn covers_bodies(self) -> bool {
matches!(self, Fixity::Full)
}
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"off" => Some(Self::Off),
"attachments" => Some(Self::Payloads),
"all" => Some(Self::Full),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Payloads => "attachments",
Self::Full => "all",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceConfig {
pub identity: Registration,
pub notation: Notation,
pub path_style: PathStyle,
pub reference_target: Addressing,
pub reference_label: bool,
pub relation_styles: BTreeMap<String, RelationStyleConfig>,
pub id_storage: IdStorage,
pub default_embed_format: fig::Format,
pub embed_style: EmbedStyle,
pub content_format: ContentFormat,
pub recycle_bin: bool,
pub fixity: Fixity,
pub updated: String,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
identity: Registration::LAZY,
notation: Notation::Markdown,
path_style: PathStyle::Root,
reference_target: Addressing::Path,
reference_label: false,
relation_styles: BTreeMap::new(),
id_storage: IdStorage::Frontmatter,
default_embed_format: fig::Format::Yaml,
embed_style: EmbedStyle::Delimited,
content_format: ContentFormat::Markdown,
recycle_bin: true,
fixity: Fixity::Payloads,
updated: String::new(),
}
}
}
impl WorkspaceConfig {
pub fn paths_only() -> Self {
Self {
identity: Registration::OFF,
id_storage: IdStorage::Registry,
..Self::default()
}
}
pub fn stable_ids() -> Self {
Self {
identity: Registration::LAZY,
reference_target: Addressing::Id,
id_storage: IdStorage::Registry,
..Self::default()
}
}
pub fn link_format(&self) -> LinkStyle {
LinkStyle::from_axes(self.notation, self.path_style)
}
pub fn reference_style(&self) -> ReferenceStyle {
ReferenceStyle {
wrapper: self.notation.wrapper(),
addressing: self.reference_target,
label: self.reference_label,
path_style: LinkStyle::from_axes(self.notation, self.path_style),
}
.normalized()
}
pub fn resolved_relation_styles(&self) -> BTreeMap<String, ReferenceStyle> {
let base = self.reference_style();
let base_notation = Notation::from_wrapper(base.wrapper, base.path_style);
let base_path = base.path_style.axes().1;
self.relation_styles
.iter()
.map(|(name, over)| {
let notation = over.notation.unwrap_or(base_notation);
let path = over.path_style.unwrap_or(base_path);
let style = ReferenceStyle {
wrapper: notation.wrapper(),
addressing: over.target.unwrap_or(base.addressing),
label: over.label.unwrap_or(base.label),
path_style: LinkStyle::from_axes(notation, path),
}
.normalized();
(name.clone(), style)
})
.collect()
}
pub fn mints_on_mutation(&self) -> bool {
let link_registers = self.reference_style().registers()
|| self
.resolved_relation_styles()
.values()
.any(|s| s.registers());
(link_registers && self.identity.fires_on(crate::identity::Trigger::Link))
|| self.identity.fires_on(crate::identity::Trigger::Create)
}
pub fn apply(&mut self, meta: &Value) {
if let Some(v) = meta
.get("content_format")
.and_then(Value::as_str)
.and_then(ContentFormat::from_config_str)
{
self.content_format = v;
}
if let Some(md) = meta.get("metadata") {
if let Some(v) = md
.get("format")
.and_then(Value::as_str)
.and_then(format_from_str)
{
self.default_embed_format = v;
}
if let Some(v) = md
.get("embed")
.and_then(Value::as_str)
.and_then(EmbedStyle::from_config_str)
{
self.embed_style = v;
}
}
if let Some(rf) = meta.get("references") {
if let Some(v) = rf
.get("notation")
.and_then(Value::as_str)
.and_then(Notation::from_config_str)
{
self.notation = v;
}
if let Some(v) = rf
.get("path_style")
.and_then(Value::as_str)
.and_then(PathStyle::from_config_str)
{
self.path_style = v;
}
if let Some(v) = rf
.get("target")
.and_then(Value::as_str)
.and_then(Addressing::from_config_str)
{
self.reference_target = v;
}
if let Some(v) = rf.get("label").and_then(Value::as_bool) {
self.reference_label = v;
}
}
if let Some(relations) = meta.get("relations").and_then(Value::as_mapping) {
for (name, spec) in relations {
let entry = self.relation_styles.entry(name.clone()).or_default();
if let Some(v) = spec
.get("notation")
.and_then(Value::as_str)
.and_then(Notation::from_config_str)
{
entry.notation = Some(v);
}
if let Some(v) = spec
.get("path_style")
.and_then(Value::as_str)
.and_then(PathStyle::from_config_str)
{
entry.path_style = Some(v);
}
if let Some(v) = spec
.get("target")
.and_then(Value::as_str)
.and_then(Addressing::from_config_str)
{
entry.target = Some(v);
}
if let Some(v) = spec.get("label").and_then(Value::as_bool) {
entry.label = Some(v);
}
}
}
if let Some(v) = meta
.get("id_storage")
.and_then(Value::as_str)
.and_then(IdStorage::from_config_str)
{
self.id_storage = v;
}
if let Some(v) = meta.get("updated").and_then(Value::as_str) {
self.updated = v.to_string();
}
if let Some(v) = meta
.get("identity")
.and_then(Value::as_str)
.and_then(registration_from_str)
{
self.identity = v;
}
if let Some(v) = meta
.get("fixity")
.and_then(Value::as_str)
.and_then(Fixity::from_config_str)
{
self.fixity = v;
}
if let Some(v) = meta.get("recycle_bin").and_then(Value::as_bool) {
self.recycle_bin = v;
}
}
pub fn from_meta(meta: &Value) -> Self {
let mut config = Self::default();
config.apply(meta);
config
}
pub fn to_mapping(&self) -> Mapping {
let mut map = Mapping::new();
map.insert("spec".into(), Value::Int(SPEC_VERSION));
map.insert(
"content_format".into(),
Value::String(self.content_format.as_config_str().into()),
);
let mut metadata = Mapping::new();
metadata.insert(
"format".into(),
Value::String(format_str(self.default_embed_format).into()),
);
metadata.insert(
"embed".into(),
Value::String(self.embed_style.as_config_str().into()),
);
map.insert("metadata".into(), Value::Mapping(metadata));
let mut references = Mapping::new();
references.insert(
"notation".into(),
Value::String(self.notation.as_config_str().into()),
);
references.insert(
"path_style".into(),
Value::String(self.path_style.as_config_str().into()),
);
references.insert(
"target".into(),
Value::String(self.reference_target.as_config_str().into()),
);
references.insert("label".into(), Value::Bool(self.reference_label));
map.insert("references".into(), Value::Mapping(references));
if !self.relation_styles.is_empty() {
let mut relations = Mapping::new();
for (name, over) in &self.relation_styles {
let mut spec = Mapping::new();
if let Some(n) = over.notation {
spec.insert("notation".into(), Value::String(n.as_config_str().into()));
}
if let Some(p) = over.path_style {
spec.insert("path_style".into(), Value::String(p.as_config_str().into()));
}
if let Some(t) = over.target {
spec.insert("target".into(), Value::String(t.as_config_str().into()));
}
if let Some(l) = over.label {
spec.insert("label".into(), Value::Bool(l));
}
relations.insert(name.clone(), Value::Mapping(spec));
}
map.insert("relations".into(), Value::Mapping(relations));
}
map.insert(
"id_storage".into(),
Value::String(self.id_storage.as_config_str().into()),
);
map.insert("updated".into(), Value::String(self.updated.clone()));
map.insert(
"identity".into(),
Value::String(registration_str(self.identity).into()),
);
map.insert(
"fixity".into(),
Value::String(self.fixity.as_config_str().into()),
);
map.insert("recycle_bin".into(), Value::Bool(self.recycle_bin));
map
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigIssue {
pub key: String,
pub kind: ConfigIssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigIssueKind {
UnknownKey { suggestion: String },
InvalidValue {
value: String,
expected: Vec<String>,
},
}
const TOP_KEYS: &[&str] = &[
"spec",
"content_format",
"metadata",
"references",
"relations",
"id_storage",
"updated",
"identity",
"fixity",
"recycle_bin",
];
const METADATA_KEYS: &[&str] = &["format", "embed"];
const REFERENCE_KEYS: &[&str] = &["notation", "path_style", "target", "label"];
pub fn spec_ahead(meta: &Value) -> Option<i64> {
match meta.get("spec") {
Some(Value::Int(v)) if *v > SPEC_VERSION => Some(*v),
_ => None,
}
}
pub fn diagnose(meta: &Value) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
let Some(map) = meta.as_mapping() else {
return issues;
};
for (key, value) in map {
match key.as_str() {
"spec" => {} "content_format" => {
enum_axis(
&mut issues,
key,
value,
|s| ContentFormat::from_config_str(s).is_some(),
&["markdown", "djot", "html"],
);
}
"id_storage" => {
enum_axis(
&mut issues,
key,
value,
|s| IdStorage::from_config_str(s).is_some(),
&["registry", "frontmatter", "both"],
);
}
"identity" => {
enum_axis(
&mut issues,
key,
value,
|s| registration_from_str(s).is_some(),
&["none", "lazy", "eager"],
);
}
"fixity" => {
enum_axis(
&mut issues,
key,
value,
|s| Fixity::from_config_str(s).is_some(),
&["off", "attachments", "all"],
);
}
"recycle_bin" => bool_axis(&mut issues, key, value),
"updated" => {} "metadata" => diagnose_metadata(&mut issues, value),
"references" => diagnose_reference_block(&mut issues, "references", value),
"relations" => diagnose_relations(&mut issues, value),
other => {
if let Some(suggestion) = nearest(other, TOP_KEYS) {
issues.push(unknown(key.clone(), suggestion));
}
}
}
}
issues
}
fn diagnose_metadata(issues: &mut Vec<ConfigIssue>, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "metadata", value);
};
for (key, v) in map {
let dotted = format!("metadata.{key}");
match key.as_str() {
"format" => enum_axis(
issues,
&dotted,
v,
|s| format_from_str(s).is_some(),
&embed_format_spellings(),
),
"embed" => enum_axis(
issues,
&dotted,
v,
|s| EmbedStyle::from_config_str(s).is_some(),
&[
"delimited",
"code_block",
"html_script",
"html_code",
"separate",
],
),
other => {
if let Some(sug) = nearest(other, METADATA_KEYS) {
issues.push(unknown(dotted, format!("metadata.{sug}")));
}
}
}
}
}
fn diagnose_reference_block(issues: &mut Vec<ConfigIssue>, prefix: &str, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, prefix, value);
};
for (key, v) in map {
let dotted = format!("{prefix}.{key}");
match key.as_str() {
"notation" => enum_axis(
issues,
&dotted,
v,
|s| Notation::from_config_str(s).is_some(),
&["markdown", "wikilink", "bare"],
),
"path_style" => enum_axis(
issues,
&dotted,
v,
|s| PathStyle::from_config_str(s).is_some(),
&["root", "relative", "canonical"],
),
"target" => enum_axis(
issues,
&dotted,
v,
|s| Addressing::from_config_str(s).is_some(),
&["path", "id", "alias"],
),
"label" => bool_axis(issues, &dotted, v),
other => {
if let Some(sug) = nearest(other, REFERENCE_KEYS) {
issues.push(unknown(dotted, format!("{prefix}.{sug}")));
}
}
}
}
}
fn diagnose_relations(issues: &mut Vec<ConfigIssue>, value: &Value) {
let Some(map) = value.as_mapping() else {
return block_shape_issue(issues, "relations", value);
};
for (name, spec) in map {
diagnose_reference_block(issues, &format!("relations.{name}"), spec);
}
}
fn block_shape_issue(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: vec!["a block of keys".into()],
},
});
}
fn enum_axis(
issues: &mut Vec<ConfigIssue>,
key: &str,
value: &Value,
parses: impl Fn(&str) -> bool,
expected: &[&str],
) {
if !value.as_str().is_some_and(parses) {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: expected.iter().map(|s| s.to_string()).collect(),
},
});
}
}
fn bool_axis(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
if value.as_bool().is_none() {
issues.push(ConfigIssue {
key: key.to_string(),
kind: ConfigIssueKind::InvalidValue {
value: value_summary(value),
expected: vec!["true".into(), "false".into()],
},
});
}
}
fn unknown(key: String, suggestion: String) -> ConfigIssue {
ConfigIssue {
key,
kind: ConfigIssueKind::UnknownKey { suggestion },
}
}
fn embed_format_spellings() -> Vec<&'static str> {
#[allow(unused_mut)]
let mut v = vec!["yaml"];
#[cfg(feature = "json")]
v.push("json");
#[cfg(feature = "toml")]
v.push("toml");
#[cfg(feature = "fig-lang")]
v.push("fig");
v
}
fn value_summary(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
_ => "(non-scalar)".to_string(),
}
}
fn nearest(key: &str, candidates: &[&str]) -> Option<String> {
candidates
.iter()
.map(|cand| (levenshtein(key, cand), *cand))
.filter(|(d, _)| (1..=2).contains(d))
.min_by_key(|(d, _)| *d)
.map(|(_, cand)| cand.to_string())
}
fn levenshtein(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == *cb { 0 } else { 1 };
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
pub fn metadata_format_from_str(value: &str) -> Option<fig::Format> {
format_from_str(value)
}
pub fn metadata_format_str(format: fig::Format) -> &'static str {
format_str(format)
}
fn format_from_str(value: &str) -> Option<fig::Format> {
match value {
"yaml" | "yml" => Some(fig::Format::Yaml),
#[cfg(feature = "json")]
"json" => Some(fig::Format::Json),
#[cfg(feature = "toml")]
"toml" => Some(fig::Format::Toml),
#[cfg(feature = "fig-lang")]
"fig" => Some(fig::Format::Fig),
_ => None,
}
}
fn format_str(format: fig::Format) -> &'static str {
match format {
#[cfg(feature = "json")]
fig::Format::Json => "json",
#[cfg(feature = "toml")]
fig::Format::Toml => "toml",
#[cfg(feature = "fig-lang")]
fig::Format::Fig => "fig",
_ => "yaml",
}
}
fn registration_from_str(value: &str) -> Option<Registration> {
match value {
"none" | "off" => Some(Registration::OFF),
"lazy" => Some(Registration::LAZY),
"eager" => Some(Registration::EAGER),
_ => None,
}
}
fn registration_str(registration: Registration) -> &'static str {
match registration {
Registration::OFF => "none",
Registration::EAGER => "eager",
_ => "lazy",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Trigger;
fn config_doc(pairs: &[(&str, &str)]) -> Value {
let mut map = Mapping::new();
for (k, v) in pairs {
let value = match *v {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
other => Value::String(other.into()),
};
map.insert((*k).into(), value);
}
Value::Mapping(map)
}
#[test]
fn presets_encode_the_two_styles() {
assert_eq!(WorkspaceConfig::paths_only().identity, Registration::OFF);
assert_eq!(
WorkspaceConfig::paths_only().reference_target,
Addressing::Path
);
assert!(
WorkspaceConfig::stable_ids()
.identity
.fires_on(Trigger::Link)
);
assert_eq!(
WorkspaceConfig::stable_ids().reference_target,
Addressing::Id
);
}
#[test]
fn round_trips_through_a_nested_mapping() {
let config = WorkspaceConfig {
identity: Registration::EAGER,
notation: Notation::Bare,
path_style: PathStyle::Canonical,
reference_target: Addressing::Id,
reference_label: true,
relation_styles: BTreeMap::from([
(
"contents".to_string(),
RelationStyleConfig {
notation: Some(Notation::Wikilink),
path_style: None,
target: Some(Addressing::Alias),
label: None,
},
),
(
"part_of".to_string(),
RelationStyleConfig {
notation: Some(Notation::Markdown),
path_style: Some(PathStyle::Relative),
target: Some(Addressing::Id),
label: Some(false),
},
),
]),
id_storage: IdStorage::Frontmatter,
default_embed_format: fig::Format::Yaml,
embed_style: EmbedStyle::CodeBlock,
content_format: ContentFormat::Djot,
recycle_bin: false,
fixity: Fixity::Full,
updated: "modified".to_string(),
};
let back = WorkspaceConfig::from_meta(&Value::Mapping(config.to_mapping()));
assert_eq!(back, config);
}
#[test]
fn per_relation_styles_resolve_over_the_workspace_default() {
let mut cfg = WorkspaceConfig::default();
cfg.apply(&config_doc_nested(
&[("target", "id")],
&[
("contents", &[("notation", "wikilink"), ("target", "alias")]),
("part_of", &[("target", "id")]),
],
));
let styles = cfg.resolved_relation_styles();
let down = styles.get("contents").expect("contents style");
assert_eq!(down.wrapper, crate::link::Wrapper::Wikilink);
assert_eq!(down.addressing, Addressing::Alias);
let up = styles.get("part_of").expect("part_of style");
assert_eq!(up.wrapper, crate::link::Wrapper::Markdown);
assert_eq!(up.addressing, Addressing::Id);
}
fn config_doc_nested(
references: &[(&str, &str)],
relations: &[(&str, &[(&str, &str)])],
) -> Value {
let mut top = Mapping::new();
let mut refs = Mapping::new();
for (k, v) in references {
refs.insert((*k).into(), Value::String((*v).into()));
}
top.insert("references".into(), Value::Mapping(refs));
let mut rels = Mapping::new();
for (name, axes) in relations {
let mut spec = Mapping::new();
for (k, v) in *axes {
spec.insert((*k).into(), Value::String((*v).into()));
}
rels.insert((*name).into(), Value::Mapping(spec));
}
top.insert("relations".into(), Value::Mapping(rels));
Value::Mapping(top)
}
#[test]
fn reference_axes_orthogonalize_notation_and_resolution() {
let mut cfg = WorkspaceConfig::default();
let mut refs = Mapping::new();
refs.insert("notation".into(), Value::String("bare".into()));
refs.insert("path_style".into(), Value::String("canonical".into()));
let mut top = Mapping::new();
top.insert("references".into(), Value::Mapping(refs));
cfg.apply(&Value::Mapping(top));
assert_eq!(cfg.link_format(), LinkStyle::PlainCanonical);
assert_eq!(cfg.notation, Notation::Bare);
assert_eq!(cfg.path_style, PathStyle::Canonical);
}
#[test]
fn apply_overlays_only_present_keys_so_the_config_document_wins() {
let mut config = WorkspaceConfig::default();
config.apply(&config_doc(&[("content_format", "djot")]));
assert_eq!(config.content_format, ContentFormat::Djot);
assert_eq!(config.identity, Registration::LAZY, "identity untouched");
config.apply(&config_doc(&[("identity", "none")]));
assert_eq!(config.identity, Registration::OFF);
assert_eq!(config.content_format, ContentFormat::Djot);
}
#[test]
fn diagnose_is_silent_on_a_clean_config_and_on_user_fields() {
let doc = config_doc(&[
("title", "prov config"),
("part_of", "index.md"),
("id", "abc123"),
("spec", "1"),
("identity", "lazy"),
("fixity", "all"),
("recycle_bin", "false"),
("content_format", "djot"),
("id_storage", "both"),
("author", "someone"),
]);
assert!(diagnose(&doc).is_empty(), "flagged: {:?}", diagnose(&doc));
}
#[test]
fn diagnose_flags_a_misspelled_top_level_key_with_a_suggestion() {
let issues = diagnose(&config_doc(&[("recyle_bin", "false")]));
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0].kind,
ConfigIssueKind::UnknownKey {
suggestion: "recycle_bin".into()
}
);
}
#[test]
fn diagnose_flags_bad_values_and_typos_inside_nested_blocks() {
let mut refs = Mapping::new();
refs.insert("notaton".into(), Value::String("markdown".into()));
refs.insert("target".into(), Value::String("pointer".into()));
let mut top = Mapping::new();
top.insert("references".into(), Value::Mapping(refs));
let issues = diagnose(&Value::Mapping(top));
assert!(
issues.iter().any(|i| i.key == "references.notaton"
&& matches!(&i.kind, ConfigIssueKind::UnknownKey { suggestion } if suggestion == "references.notation")),
"{issues:?}"
);
assert!(
issues.iter().any(|i| i.key == "references.target"
&& matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "pointer")),
"{issues:?}"
);
}
#[test]
fn diagnose_flags_an_unrecognized_value_on_a_real_key() {
let issues = diagnose(&config_doc(&[("fixity", "alll")]));
assert_eq!(issues.len(), 1);
match &issues[0].kind {
ConfigIssueKind::InvalidValue { value, expected } => {
assert_eq!(value, "alll");
assert!(expected.contains(&"all".to_string()), "{expected:?}");
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn spec_ahead_fires_only_for_a_newer_spec() {
assert_eq!(
spec_ahead(&config_doc(&[("identity", "lazy")])),
None,
"absent spec"
);
let at = {
let mut m = Mapping::new();
m.insert("spec".into(), Value::Int(SPEC_VERSION));
Value::Mapping(m)
};
assert_eq!(spec_ahead(&at), None, "current spec is fine");
let ahead = {
let mut m = Mapping::new();
m.insert("spec".into(), Value::Int(SPEC_VERSION + 1));
Value::Mapping(m)
};
assert_eq!(spec_ahead(&ahead), Some(SPEC_VERSION + 1));
}
#[test]
fn serialized_defaults_and_presets_all_pass_diagnosis() {
for config in [
WorkspaceConfig::default(),
WorkspaceConfig::paths_only(),
WorkspaceConfig::stable_ids(),
] {
let serialized = Value::Mapping(config.to_mapping());
assert!(
diagnose(&serialized).is_empty(),
"flagged itself: {:?}",
diagnose(&serialized)
);
}
}
}