1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use thiserror::Error;
8
9pub const PROTOCOL_VERSION: &str = "v0";
11
12pub const PROFILE_CORE_V0: &str = "core/v0";
14pub const PROFILE_ADMIN_V0: &str = "admin/v0";
16pub const PROFILE_QUERY_V0: &str = "query/v0";
18
19pub const FEATURE_NAMESPACES_CREATE: &str = "core.namespaces.create";
21pub const FEATURE_NAMESPACES_FORK: &str = "core.namespaces.fork";
23pub const FEATURE_NAMESPACES_DELETE: &str = "core.namespaces.delete";
25pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "core.uploads.direct_put";
27pub const FEATURE_UPLOADS_DIRECT_MULTIPART: &str = "core.uploads.direct_multipart";
29pub const FEATURE_DOWNLOADS_DIRECT_GET: &str = "core.downloads.direct_get";
34pub const FEATURE_QUERY_GREP: &str = "query.grep";
37
38pub const LIMIT_UPLOAD_MAX_CONTENT_BYTES: &str = "upload.max_content_bytes";
41pub const LIMIT_DOWNLOAD_MAX_CONTENT_BYTES: &str = "download.max_content_bytes";
44pub const LIMIT_UPLOAD_MAX_CONCURRENT: &str = "upload.max_concurrent";
47pub const LIMIT_DOWNLOAD_MAX_CONCURRENT: &str = "download.max_concurrent";
50pub const LIMIT_COMMIT_MAX_OPERATIONS: &str = "commit.max_operations";
53pub const LIMIT_COMMIT_MAX_CONTENT_TOKENS: &str = "commit.max_content_tokens";
55pub const LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS: &str = "commit.max_external_content_refs";
58pub const LIMIT_COMMIT_MAX_MESSAGE_BYTES: &str = "commit.max_message_bytes";
60pub const LIMIT_PAGINATION_DEFAULT: &str = "pagination.default_limit";
62pub const LIMIT_PAGINATION_MAX: &str = "pagination.max_limit";
64pub const LIMIT_GC_MIN_GRACE_WINDOW_MS: &str = "maintenance.gc.min_grace_window_ms";
68pub const LIMIT_QUERY_GREP_DEFAULT: &str = "query.grep.default_limit";
70pub const LIMIT_QUERY_GREP_MAX: &str = "query.grep.max_limit";
73pub const LIMIT_QUERY_GREP_SCAN_BUDGET_FILES: &str = "query.grep.scan_budget_files";
76pub const LIMIT_QUERY_GREP_TAIL_BUDGET_FILES: &str = "query.grep.tail_budget_files";
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92pub struct CapabilityDocument {
93 pub protocol_version: String,
95 pub profiles: Vec<String>,
98 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101 pub features: BTreeMap<String, bool>,
102 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104 pub limits: BTreeMap<String, u64>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Error)]
109pub enum CapabilityDocumentError {
110 #[error(
112 "feature `{feature}` is not parented by an advertised profile \
113 (its first dotted segment must be one of the advertised plane names)"
114 )]
115 UnparentedFeature {
116 feature: String,
118 },
119}
120
121impl CapabilityDocument {
122 pub fn has_profile(&self, profile: &str) -> bool {
124 self.profiles.iter().any(|advertised| advertised == profile)
125 }
126
127 pub fn supports(&self, feature: &str) -> bool {
130 self.features.get(feature).copied().unwrap_or(false)
131 }
132
133 pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
137 for feature in self.features.keys() {
138 if !self.feature_is_parented(feature) {
139 return Err(CapabilityDocumentError::UnparentedFeature {
140 feature: feature.clone(),
141 });
142 }
143 }
144 Ok(())
145 }
146
147 pub fn retain_well_formed(&mut self) {
150 let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
151 self.features
152 .retain(|feature, _| feature_is_parented(&advertised_planes, feature));
153 }
154
155 fn feature_is_parented(&self, feature: &str) -> bool {
156 let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
157 feature_is_parented(&advertised_planes, feature)
158 }
159}
160
161fn feature_is_parented(planes: &[&str], feature: &str) -> bool {
162 match feature.split('.').next() {
163 Some(plane) if !plane.is_empty() => planes.contains(&plane),
164 _ => false,
165 }
166}
167
168fn plane_name(profile: &str) -> &str {
170 profile.split('/').next().unwrap_or(profile)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn document() -> CapabilityDocument {
178 CapabilityDocument {
179 protocol_version: PROTOCOL_VERSION.to_owned(),
180 profiles: vec![PROFILE_CORE_V0.to_owned(), PROFILE_ADMIN_V0.to_owned()],
181 features: BTreeMap::from([
182 (FEATURE_NAMESPACES_CREATE.to_owned(), true),
183 (FEATURE_NAMESPACES_DELETE.to_owned(), false),
184 ]),
185 limits: BTreeMap::new(),
186 }
187 }
188
189 #[test]
190 fn supports_and_has_profile_answer_gating_questions() {
191 let document = document();
192 assert!(document.has_profile(PROFILE_CORE_V0));
193 assert!(!document.has_profile("query/v0"));
194 assert!(document.supports(FEATURE_NAMESPACES_CREATE));
195 assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
197 assert!(!document.supports(FEATURE_NAMESPACES_FORK));
198 }
199
200 #[test]
201 fn feature_keys_must_be_parented_by_an_advertised_profile() {
202 let mut document = document();
203 document
204 .features
205 .insert("query.index.fulltext".to_owned(), true);
206
207 assert_eq!(
208 document.validate(),
209 Err(CapabilityDocumentError::UnparentedFeature {
210 feature: "query.index.fulltext".to_owned(),
211 })
212 );
213
214 document.retain_well_formed();
215 assert!(document.validate().is_ok());
216 assert!(!document.features.contains_key("query.index.fulltext"));
217 assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
218 }
219
220 #[test]
221 fn capability_document_round_trips_and_tolerates_unknown_fields() {
222 let document = document();
223 let encoded = serde_json::to_string(&document).expect("encode");
224 let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
225 assert_eq!(decoded, document);
226
227 let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
228 let decoded: CapabilityDocument =
229 serde_json::from_str(&future).expect("unknown fields are ignored");
230 assert_eq!(decoded, document);
231 }
232}