use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use thiserror::Error;
pub const PROTOCOL_VERSION: &str = "v0";
pub const PROFILE_CORE_V0: &str = "core/v0";
pub const PROFILE_ADMIN_V0: &str = "admin/v0";
pub const PROFILE_QUERY_V0: &str = "query/v0";
pub const FEATURE_NAMESPACES_CREATE: &str = "core.namespaces.create";
pub const FEATURE_NAMESPACES_FORK: &str = "core.namespaces.fork";
pub const FEATURE_NAMESPACES_DELETE: &str = "core.namespaces.delete";
pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "core.uploads.direct_put";
pub const FEATURE_UPLOADS_DIRECT_MULTIPART: &str = "core.uploads.direct_multipart";
pub const FEATURE_DOWNLOADS_DIRECT_GET: &str = "core.downloads.direct_get";
pub const FEATURE_QUERY_GREP: &str = "query.grep";
pub const LIMIT_UPLOAD_MAX_CONTENT_BYTES: &str = "upload.max_content_bytes";
pub const LIMIT_DOWNLOAD_MAX_CONTENT_BYTES: &str = "download.max_content_bytes";
pub const LIMIT_UPLOAD_MAX_CONCURRENT: &str = "upload.max_concurrent";
pub const LIMIT_DOWNLOAD_MAX_CONCURRENT: &str = "download.max_concurrent";
pub const LIMIT_COMMIT_MAX_OPERATIONS: &str = "commit.max_operations";
pub const LIMIT_COMMIT_MAX_CONTENT_TOKENS: &str = "commit.max_content_tokens";
pub const LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS: &str = "commit.max_external_content_refs";
pub const LIMIT_COMMIT_MAX_MESSAGE_BYTES: &str = "commit.max_message_bytes";
pub const LIMIT_PAGINATION_DEFAULT: &str = "pagination.default_limit";
pub const LIMIT_PAGINATION_MAX: &str = "pagination.max_limit";
pub const LIMIT_GC_MIN_GRACE_WINDOW_MS: &str = "maintenance.gc.min_grace_window_ms";
pub const LIMIT_QUERY_GREP_DEFAULT: &str = "query.grep.default_limit";
pub const LIMIT_QUERY_GREP_MAX: &str = "query.grep.max_limit";
pub const LIMIT_QUERY_GREP_SCAN_BUDGET_FILES: &str = "query.grep.scan_budget_files";
pub const LIMIT_QUERY_GREP_TAIL_BUDGET_FILES: &str = "query.grep.tail_budget_files";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CapabilityDocument {
pub protocol_version: String,
pub profiles: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub features: BTreeMap<String, bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub limits: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CapabilityDocumentError {
#[error(
"feature `{feature}` is not parented by an advertised profile \
(its first dotted segment must be one of the advertised plane names)"
)]
UnparentedFeature {
feature: String,
},
}
impl CapabilityDocument {
pub fn has_profile(&self, profile: &str) -> bool {
self.profiles.iter().any(|advertised| advertised == profile)
}
pub fn supports(&self, feature: &str) -> bool {
self.features.get(feature).copied().unwrap_or(false)
}
pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
for feature in self.features.keys() {
if !self.feature_is_parented(feature) {
return Err(CapabilityDocumentError::UnparentedFeature {
feature: feature.clone(),
});
}
}
Ok(())
}
pub fn retain_well_formed(&mut self) {
let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
self.features
.retain(|feature, _| feature_is_parented(&advertised_planes, feature));
}
fn feature_is_parented(&self, feature: &str) -> bool {
let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
feature_is_parented(&advertised_planes, feature)
}
}
fn feature_is_parented(planes: &[&str], feature: &str) -> bool {
match feature.split('.').next() {
Some(plane) if !plane.is_empty() => planes.contains(&plane),
_ => false,
}
}
fn plane_name(profile: &str) -> &str {
profile.split('/').next().unwrap_or(profile)
}
#[cfg(test)]
mod tests {
use super::*;
fn document() -> CapabilityDocument {
CapabilityDocument {
protocol_version: PROTOCOL_VERSION.to_owned(),
profiles: vec![PROFILE_CORE_V0.to_owned(), PROFILE_ADMIN_V0.to_owned()],
features: BTreeMap::from([
(FEATURE_NAMESPACES_CREATE.to_owned(), true),
(FEATURE_NAMESPACES_DELETE.to_owned(), false),
]),
limits: BTreeMap::new(),
}
}
#[test]
fn supports_and_has_profile_answer_gating_questions() {
let document = document();
assert!(document.has_profile(PROFILE_CORE_V0));
assert!(!document.has_profile("query/v0"));
assert!(document.supports(FEATURE_NAMESPACES_CREATE));
assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
assert!(!document.supports(FEATURE_NAMESPACES_FORK));
}
#[test]
fn feature_keys_must_be_parented_by_an_advertised_profile() {
let mut document = document();
document
.features
.insert("query.index.fulltext".to_owned(), true);
assert_eq!(
document.validate(),
Err(CapabilityDocumentError::UnparentedFeature {
feature: "query.index.fulltext".to_owned(),
})
);
document.retain_well_formed();
assert!(document.validate().is_ok());
assert!(!document.features.contains_key("query.index.fulltext"));
assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
}
#[test]
fn capability_document_round_trips_and_tolerates_unknown_fields() {
let document = document();
let encoded = serde_json::to_string(&document).expect("encode");
let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, document);
let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
let decoded: CapabilityDocument =
serde_json::from_str(&future).expect("unknown fields are ignored");
assert_eq!(decoded, document);
}
}