use std::collections::BTreeMap;
use crate::ext::{Ext, ExtValue};
use crate::names::Namespace;
const NAMESPACE: &str = "openspec";
const CAPABILITY: &str = "capability";
const PURPOSE: &str = "purpose";
const NAME: &str = "name";
const MODAL: &str = "modal";
const METADATA: &str = "metadata";
const LABEL: &str = "label";
const VALUE: &str = "value";
pub(super) fn feature(capability: &str, placeholder: Option<String>) -> Ext {
openspec([
(CAPABILITY, Some(text(capability))),
(PURPOSE, placeholder.map(ExtValue::Text)),
])
}
pub(super) fn requirement(name: &str, modal: Option<&str>, metadata: Vec<(String, String)>) -> Ext {
let metadata = (!metadata.is_empty()).then(|| {
ExtValue::List(
metadata
.into_iter()
.map(|(label, value)| {
ExtValue::Map(BTreeMap::from([
(LABEL.to_owned(), ExtValue::Text(label)),
(VALUE.to_owned(), ExtValue::Text(value)),
]))
})
.collect(),
)
});
openspec([
(NAME, Some(text(name))),
(MODAL, modal.map(text)),
(METADATA, metadata),
])
}
fn openspec<const N: usize>(entries: [(&str, Option<ExtValue>); N]) -> Ext {
let entries: BTreeMap<String, ExtValue> = entries
.into_iter()
.filter_map(|(key, value)| Some((key.to_owned(), value?)))
.collect();
Ext::from([(
Namespace::parse(NAMESPACE).expect("a namespace"),
ExtValue::Map(entries),
)])
}
fn text(value: &str) -> ExtValue {
ExtValue::Text(value.to_owned())
}
fn field<'a>(ext: &'a Ext, field: &str) -> Option<&'a str> {
ext.get(NAMESPACE)?.get(field)?.text()
}
pub(crate) fn capability(ext: &Ext) -> Option<&str> {
field(ext, CAPABILITY).filter(|path| safe(path))
}
fn safe(path: &str) -> bool {
path.split('/')
.all(|segment| !segment.is_empty() && !segment.starts_with('.') && !segment.contains('\\'))
}
pub(crate) fn purpose(ext: &Ext) -> Option<&str> {
field(ext, PURPOSE)
}
pub(crate) fn name(ext: &Ext) -> Option<&str> {
field(ext, NAME)
}
const MODALS: [&str; 3] = ["SHALL", "MUST", "shall"];
pub(crate) fn modal(ext: &Ext) -> &str {
field(ext, MODAL)
.filter(|word| MODALS.contains(word))
.unwrap_or(MODALS[0])
}
pub(crate) fn metadata(ext: &Ext) -> Vec<(&str, &str)> {
let Some(ExtValue::List(entries)) = ext.get(NAMESPACE).and_then(|value| value.get(METADATA))
else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let text = |field| entry.get(field).and_then(ExtValue::text);
Some((text(LABEL)?, text(VALUE)?))
})
.collect()
}
pub(crate) fn unreadable_feature(ext: &Ext) -> Vec<String> {
unreadable(ext, |entries, bad| {
bad(
CAPABILITY,
entries
.get(CAPABILITY)
.is_some_and(|value| value.text().is_none_or(|path| !safe(path))),
);
bad(
PURPOSE,
entries
.get(PURPOSE)
.is_some_and(|value| value.text().is_none()),
);
})
}
pub(crate) fn unreadable_requirement(ext: &Ext) -> Vec<String> {
unreadable(ext, |entries, bad| {
bad(
NAME,
entries
.get(NAME)
.is_some_and(|value| value.text().is_none()),
);
let modal = entries.get(MODAL);
bad(
MODAL,
modal.is_some_and(|value| value.text().is_none_or(|word| !MODALS.contains(&word))),
);
bad(
METADATA,
entries
.get(METADATA)
.is_some_and(|value| !readable_metadata(value)),
);
})
}
fn unreadable(
ext: &Ext,
check: impl FnOnce(&BTreeMap<String, ExtValue>, &mut dyn FnMut(&str, bool)),
) -> Vec<String> {
match ext.get(NAMESPACE) {
None => Vec::new(),
Some(ExtValue::Map(entries)) => {
let mut fields = Vec::new();
check(entries, &mut |field, bad| {
if bad {
fields.push(format!("ext.{NAMESPACE}.{field}"));
}
});
fields
}
Some(_) => vec![format!("ext.{NAMESPACE}")],
}
}
fn readable_metadata(value: &ExtValue) -> bool {
let ExtValue::List(entries) = value else {
return false;
};
entries.iter().all(|entry| {
[LABEL, VALUE]
.iter()
.all(|field| entry.get(field).and_then(ExtValue::text).is_some())
})
}