1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::wire::DiagnosticSeverity;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Schema {
25 pub schema_version: u32,
27 pub label: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub description: Option<String>,
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33 pub params: BTreeMap<String, ParamSpec>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub attaches_to: Vec<String>,
37 #[serde(default)]
39 pub body: BodyShape,
40 #[serde(default)]
42 pub verbatim_label: bool,
43 #[serde(default)]
46 pub capabilities: Capabilities,
47 #[serde(default)]
49 pub hooks: HookSet,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub handler: Option<HandlerSpec>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub diagnostics: Vec<DiagnosticDecl>,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct DiagnosticDecl {
75 pub code: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub description: Option<String>,
81 #[serde(
95 default = "default_decl_severity",
96 deserialize_with = "deserialize_strict_severity"
97 )]
98 pub default_severity: DiagnosticSeverity,
99}
100
101fn default_decl_severity() -> DiagnosticSeverity {
102 DiagnosticSeverity::Warning
103}
104
105fn deserialize_strict_severity<'de, D>(deserializer: D) -> Result<DiagnosticSeverity, D::Error>
111where
112 D: serde::Deserializer<'de>,
113{
114 use serde::de::{Error, Unexpected};
115 let s = String::deserialize(deserializer)?;
116 match s.as_str() {
117 "error" => Ok(DiagnosticSeverity::Error),
118 "warning" => Ok(DiagnosticSeverity::Warning),
119 "info" => Ok(DiagnosticSeverity::Info),
120 "hint" => Ok(DiagnosticSeverity::Hint),
121 _ => Err(D::Error::invalid_value(
122 Unexpected::Str(&s),
123 &"one of: error, warning, info, hint",
124 )),
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct ParamSpec {
132 #[serde(rename = "type")]
133 pub ty: ParamType,
134 #[serde(default)]
135 pub required: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub default: Option<serde_json::Value>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub description: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub pattern: Option<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub values: Vec<EnumValue>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "lowercase")]
156#[non_exhaustive]
157pub enum ParamType {
158 String,
159 Bool,
160 Int,
161 Float,
162 Enum,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct EnumValue {
169 pub name: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub description: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct BodyShape {
178 #[serde(default = "BodyKind::default_kind")]
179 pub kind: BodyKind,
180 #[serde(default)]
181 pub presence: BodyPresence,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub description: Option<String>,
184}
185
186impl Default for BodyShape {
187 fn default() -> Self {
188 Self {
189 kind: BodyKind::None,
190 presence: BodyPresence::Optional,
191 description: None,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198#[non_exhaustive]
199pub enum BodyKind {
200 None,
201 Text,
202 Lex,
203}
204
205impl BodyKind {
206 fn default_kind() -> Self {
207 Self::None
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
212#[serde(rename_all = "lowercase")]
213#[non_exhaustive]
214pub enum BodyPresence {
215 Optional,
216 Required,
217}
218
219impl Default for BodyPresence {
220 fn default() -> Self {
221 Self::Optional
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Capabilities {
230 #[serde(default)]
231 pub fs: bool,
232 #[serde(default)]
233 pub net: bool,
234}
235
236impl Capabilities {
237 pub fn is_pure(&self) -> bool {
248 *self == Self::default()
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct HookSet {
266 #[serde(default)]
267 pub label: bool,
268 #[serde(default)]
269 pub validate: bool,
270 #[serde(default)]
271 pub resolve: bool,
272 #[serde(default)]
278 pub ir_build: bool,
279 #[serde(default)]
280 pub hover: bool,
281 #[serde(default)]
282 pub completion: bool,
283 #[serde(default)]
284 pub code_action: bool,
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub render: Vec<RenderHook>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
293#[serde(transparent)]
294pub struct RenderHook(pub String);
295
296impl RenderHook {
297 pub fn new(format: impl Into<String>) -> Self {
298 Self(format.into())
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct HandlerSpec {
306 pub transport: HandlerTransport,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
310 pub command: Vec<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub timeout_ms: Option<u32>,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318#[non_exhaustive]
319pub enum HandlerTransport {
320 Native,
321 Subprocess,
322 Wasm,
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn comment_schema() -> Schema {
330 let mut params = BTreeMap::new();
331 params.insert(
332 "role".into(),
333 ParamSpec {
334 ty: ParamType::Enum,
335 required: true,
336 default: None,
337 description: None,
338 pattern: None,
339 values: vec![
340 EnumValue {
341 name: "author".into(),
342 description: None,
343 },
344 EnumValue {
345 name: "editor".into(),
346 description: None,
347 },
348 ],
349 },
350 );
351 Schema {
352 schema_version: 1,
353 label: "acme.commenting".into(),
354 description: Some("A comment thread.".into()),
355 params,
356 attaches_to: vec!["paragraph".into(), "session".into()],
357 body: BodyShape {
358 kind: BodyKind::Lex,
359 presence: BodyPresence::Required,
360 description: None,
361 },
362 verbatim_label: false,
363 capabilities: Capabilities {
364 fs: false,
365 net: false,
366 },
367 hooks: HookSet {
368 validate: true,
369 hover: true,
370 render: vec![RenderHook::new("html"), RenderHook::new("markdown")],
371 ..HookSet::default()
372 },
373 handler: Some(HandlerSpec {
374 transport: HandlerTransport::Subprocess,
375 command: vec!["acme-comment-handler".into()],
376 timeout_ms: Some(2000),
377 }),
378 diagnostics: vec![
379 DiagnosticDecl {
380 code: "unresolved-thread".into(),
381 description: Some("A comment thread has no resolution.".into()),
382 default_severity: DiagnosticSeverity::Warning,
383 },
384 DiagnosticDecl {
385 code: "missing-author".into(),
386 description: None,
387 default_severity: DiagnosticSeverity::Error,
388 },
389 ],
390 }
391 }
392
393 #[test]
394 fn schema_round_trips_through_json() {
395 let s = comment_schema();
396 let serialised = serde_json::to_string(&s).unwrap();
397 let back: Schema = serde_json::from_str(&serialised).unwrap();
398 assert_eq!(back, s);
399 }
400
401 #[test]
402 fn capabilities_is_pure_for_zero_fs_zero_net() {
403 assert!(Capabilities::default().is_pure());
404 assert!(!Capabilities {
405 fs: true,
406 net: false
407 }
408 .is_pure());
409 assert!(!Capabilities {
410 fs: false,
411 net: true
412 }
413 .is_pure());
414 }
415
416 #[test]
417 fn hookset_default_is_all_off() {
418 let hs = HookSet::default();
419 assert!(!hs.validate);
420 assert!(!hs.resolve);
421 assert!(!hs.ir_build);
422 assert!(hs.render.is_empty());
423 }
424
425 #[test]
431 fn hookset_ir_build_round_trips_through_json() {
432 let hs = HookSet {
433 ir_build: true,
434 ..HookSet::default()
435 };
436 let serialised = serde_json::to_string(&hs).unwrap();
437 assert!(
438 serialised.contains("\"ir_build\":true"),
439 "ir_build must serialise: {serialised}"
440 );
441 let back: HookSet = serde_json::from_str(&serialised).unwrap();
442 assert!(back.ir_build);
443
444 let legacy = r#"{"label":false,"validate":false,"resolve":false,"hover":false,"completion":false,"code_action":false}"#;
447 let parsed: HookSet = serde_json::from_str(legacy).unwrap();
448 assert!(
449 !parsed.ir_build,
450 "legacy JSON must default ir_build to false"
451 );
452 }
453
454 #[test]
455 fn body_shape_default_is_none_optional() {
456 let bs = BodyShape::default();
457 assert_eq!(bs.kind, BodyKind::None);
458 assert_eq!(bs.presence, BodyPresence::Optional);
459 }
460
461 #[test]
462 fn schema_without_diagnostics_field_loads_empty() {
463 let s: Schema =
466 serde_json::from_str(r#"{"schema_version": 1, "label": "acme.task"}"#).unwrap();
467 assert!(s.diagnostics.is_empty());
468 }
469
470 #[test]
471 fn diagnostic_decl_default_severity_is_warning() {
472 let s: Schema = serde_json::from_str(
475 r#"{"schema_version": 1, "label": "acme.task",
476 "diagnostics": [{"code": "due-date-missing"}]}"#,
477 )
478 .unwrap();
479 assert_eq!(s.diagnostics.len(), 1);
480 assert_eq!(s.diagnostics[0].code, "due-date-missing");
481 assert_eq!(s.diagnostics[0].description, None);
482 assert_eq!(
483 s.diagnostics[0].default_severity,
484 DiagnosticSeverity::Warning
485 );
486 }
487
488 #[test]
489 fn diagnostic_decl_explicit_severity_parses() {
490 let s: Schema = serde_json::from_str(
491 r#"{"schema_version": 1, "label": "acme.task",
492 "diagnostics": [{"code": "due-date-missing",
493 "description": "Task lacks a due date.",
494 "default_severity": "error"}]}"#,
495 )
496 .unwrap();
497 assert_eq!(s.diagnostics[0].default_severity, DiagnosticSeverity::Error);
498 assert_eq!(
499 s.diagnostics[0].description.as_deref(),
500 Some("Task lacks a due date.")
501 );
502 }
503
504 #[test]
505 fn diagnostic_decl_rejects_unknown_field() {
506 assert!(serde_json::from_str::<Schema>(
507 r#"{"schema_version": 1, "label": "acme.task",
508 "diagnostics": [{"code": "due-date-missing", "severty": "warn"}]}"#,
509 )
510 .is_err());
511 }
512
513 #[test]
514 fn diagnostic_decl_rejects_unknown_severity_value() {
515 for bad in [r#""warn""#, r#""erorr""#, r#""fatal""#] {
519 let src = format!(
520 r#"{{"schema_version": 1, "label": "acme.task",
521 "diagnostics": [{{"code": "x", "default_severity": {bad}}}]}}"#
522 );
523 assert!(
524 serde_json::from_str::<Schema>(&src).is_err(),
525 "expected `{bad}` to be rejected"
526 );
527 }
528 }
529}