1use std::{collections::BTreeMap, sync::OnceLock};
2
3use serde::de::DeserializeOwned;
4use serde_json::{Map, Value, json};
5use soma_ops::{DiagnosticCode, OperationName, OperationSpec};
6
7use crate::{
8 CompatibilityError, DiagnosticProjection, LegacyOperationBinding, LegacyTool,
9 binding::LegacyBindingKey,
10 schema::{
11 CanonicalBundle, DiagnosticBundle, LegacyBundle, OperationSchemaContract, ParameterBundle,
12 ResultBundle, build_parameter_schemas, build_result_schemas,
13 },
14};
15
16const LEGACY: &str = include_str!(concat!(
17 env!("CARGO_MANIFEST_DIR"),
18 "/../../../docs/unify/03-contracts/examples/synapse-operations.json"
19));
20const CANONICAL: &str = include_str!(concat!(
21 env!("CARGO_MANIFEST_DIR"),
22 "/../../../docs/unify/03-contracts/examples/synapse-canonical-operations.json"
23));
24const PARAMETERS: &str = include_str!(concat!(
25 env!("CARGO_MANIFEST_DIR"),
26 "/../../../docs/unify/03-contracts/examples/synapse-operation-parameters.json"
27));
28const RESULTS: &str = include_str!(concat!(
29 env!("CARGO_MANIFEST_DIR"),
30 "/../../../docs/unify/03-contracts/examples/synapse-operation-results.json"
31));
32const DIAGNOSTICS: &str = include_str!(concat!(
33 env!("CARGO_MANIFEST_DIR"),
34 "/../../../docs/unify/03-contracts/examples/operation-diagnostic-projections.json"
35));
36const EXPECTED_OPERATIONS: usize = 59;
37const EXPECTED_DIAGNOSTICS: usize = 33;
38
39pub struct SynapseCatalog {
41 operations: BTreeMap<OperationName, OperationSpec>,
42 bindings: Vec<LegacyOperationBinding>,
43 binding_index: BTreeMap<LegacyBindingKey, usize>,
44 parameter_schemas: BTreeMap<OperationName, OperationSchemaContract>,
45 result_schemas: BTreeMap<OperationName, OperationSchemaContract>,
46 diagnostics: BTreeMap<DiagnosticCode, DiagnosticProjection>,
47}
48
49impl SynapseCatalog {
50 #[must_use]
52 pub fn embedded() -> &'static Self {
53 static CATALOG: OnceLock<SynapseCatalog> = OnceLock::new();
54 CATALOG.get_or_init(|| {
55 Self::try_from_embedded().expect("checked-in Synapse contracts must remain valid")
56 })
57 }
58
59 pub fn try_from_embedded() -> Result<Self, CompatibilityError> {
61 let canonical: CanonicalBundle = parse("synapse-canonical-operations.json", CANONICAL)?;
62 let legacy: LegacyBundle = parse("synapse-operations.json", LEGACY)?;
63 let parameters: ParameterBundle = parse("synapse-operation-parameters.json", PARAMETERS)?;
64 let results: ResultBundle = parse("synapse-operation-results.json", RESULTS)?;
65 let diagnostics: DiagnosticBundle =
66 parse("operation-diagnostic-projections.json", DIAGNOSTICS)?;
67
68 if canonical.operations.len() != EXPECTED_OPERATIONS
69 || legacy.operations.len() != EXPECTED_OPERATIONS
70 || parameters.schemas.len() != EXPECTED_OPERATIONS
71 || results.schemas.len() != EXPECTED_OPERATIONS
72 {
73 return contract_error("catalog", "expected 59 records in every operation artifact");
74 }
75 if diagnostics.mappings.len() != EXPECTED_DIAGNOSTICS {
76 return contract_error("catalog", "expected 33 diagnostic projections");
77 }
78 for digest in [
79 ¶meters.classification_sha256,
80 &results.classification_sha256,
81 &diagnostics.classification_sha256,
82 ] {
83 if digest != &canonical.classification_sha256 {
84 return contract_error("catalog", "classification digest mismatch");
85 }
86 }
87
88 let mut operations = BTreeMap::new();
89 for operation in canonical.operations {
90 operation
91 .validate()
92 .map_err(|error| CompatibilityError::EmbeddedContract {
93 artifact: "synapse-canonical-operations.json",
94 message: format!("{}: {error}", operation.name()),
95 })?;
96 if operations
97 .insert(operation.name().clone(), operation)
98 .is_some()
99 {
100 return contract_error("catalog", "duplicate canonical operation");
101 }
102 }
103
104 let mut bindings = Vec::with_capacity(EXPECTED_OPERATIONS);
105 let mut binding_index = BTreeMap::new();
106 for binding in legacy.operations {
107 let operation = operations.get(binding.canonical_name()).ok_or_else(|| {
108 CompatibilityError::EmbeddedContract {
109 artifact: "synapse-operations.json",
110 message: format!(
111 "binding {} targets missing {}",
112 binding.legacy_name(),
113 binding.canonical_name()
114 ),
115 }
116 })?;
117 validate_binding_parity(&binding, operation)?;
118 let key = LegacyBindingKey::new(binding.tool(), binding.action(), binding.subaction());
119 let index = bindings.len();
120 if binding_index.insert(key, index).is_some() {
121 return contract_error("synapse-operations.json", "duplicate legacy routing key");
122 }
123 bindings.push(binding);
124 }
125
126 let parameter_schemas = build_parameter_schemas(parameters, &operations)?;
127 let result_schemas = build_result_schemas(results, &operations)?;
128 let mut projected = BTreeMap::new();
129 for projection in diagnostics.mappings {
130 if projected
131 .insert(projection.code().clone(), projection)
132 .is_some()
133 {
134 return contract_error(
135 "operation-diagnostic-projections.json",
136 "duplicate diagnostic code",
137 );
138 }
139 }
140 for operation in operations.values() {
141 for code in operation.diagnostic_codes() {
142 if !projected.contains_key(code) {
143 return contract_error(
144 "operation-diagnostic-projections.json",
145 &format!("missing projection for {code}"),
146 );
147 }
148 }
149 }
150
151 Ok(Self {
152 operations,
153 bindings,
154 binding_index,
155 parameter_schemas,
156 result_schemas,
157 diagnostics: projected,
158 })
159 }
160
161 #[must_use]
163 pub fn operation_count(&self) -> usize {
164 self.operations.len()
165 }
166
167 #[must_use]
169 pub fn binding_count(&self) -> usize {
170 self.bindings.len()
171 }
172
173 #[must_use]
175 pub fn diagnostic_count(&self) -> usize {
176 self.diagnostics.len()
177 }
178
179 pub fn operations(&self) -> impl Iterator<Item = &OperationSpec> {
181 self.operations.values()
182 }
183
184 pub fn bindings(&self) -> impl Iterator<Item = &LegacyOperationBinding> {
186 self.bindings.iter()
187 }
188
189 #[must_use]
191 pub fn operation(&self, name: &OperationName) -> Option<&OperationSpec> {
192 self.operations.get(name)
193 }
194
195 #[must_use]
197 pub fn binding(
198 &self,
199 tool: LegacyTool,
200 action: &str,
201 subaction: Option<&str>,
202 ) -> Option<&LegacyOperationBinding> {
203 let direct = LegacyBindingKey::new(tool, action, subaction);
204 let shared = LegacyBindingKey::new(LegacyTool::Both, action, subaction);
205 self.binding_index
206 .get(&direct)
207 .or_else(|| self.binding_index.get(&shared))
208 .map(|index| &self.bindings[*index])
209 }
210
211 #[must_use]
213 pub fn parameter_schema(&self, operation: &OperationName) -> Option<&OperationSchemaContract> {
214 self.parameter_schemas.get(operation)
215 }
216
217 #[must_use]
219 pub fn result_schema(&self, operation: &OperationName) -> Option<&OperationSchemaContract> {
220 self.result_schemas.get(operation)
221 }
222
223 #[must_use]
225 pub fn diagnostic_projection(&self, code: &DiagnosticCode) -> Option<&DiagnosticProjection> {
226 self.diagnostics.get(code)
227 }
228
229 pub fn bindings_for_tool(
231 &self,
232 tool: LegacyTool,
233 ) -> impl Iterator<Item = &LegacyOperationBinding> {
234 self.bindings
235 .iter()
236 .filter(move |binding| binding.tool() == tool || binding.tool() == LegacyTool::Both)
237 }
238
239 #[must_use]
241 pub fn legacy_tool_schema(&self, tool: LegacyTool) -> Value {
242 let branches = self
243 .bindings_for_tool(tool)
244 .filter_map(|binding| {
245 let contract = self.parameter_schemas.get(binding.canonical_name())?;
246 let mut branch = contract.schema().clone();
247 let object = branch.as_object_mut()?;
248 object.remove("$schema");
249 object.remove("$id");
250 object.remove("title");
251 let properties = object
252 .entry("properties")
253 .or_insert_with(|| Value::Object(Map::new()))
254 .as_object_mut()?;
255 properties.insert("action".into(), json!({"const": binding.action()}));
256 if let Some(subaction) = binding.subaction() {
257 properties.insert("subaction".into(), json!({"const": subaction}));
258 }
259 let presentation = json!({"type":"string","enum":["markdown","json"]});
260 properties.insert("response_format".into(), presentation.clone());
261 properties.insert("format".into(), presentation);
262 let required = object
263 .entry("required")
264 .or_insert_with(|| Value::Array(Vec::new()))
265 .as_array_mut()?;
266 required.push(Value::String("action".into()));
267 if binding.subaction().is_some() {
268 required.push(Value::String("subaction".into()));
269 }
270 Some(branch)
271 })
272 .collect::<Vec<_>>();
273 json!({
274 "$schema": "https://json-schema.org/draft/2020-12/schema",
275 "type": "object",
276 "oneOf": branches
277 })
278 }
279}
280
281fn validate_binding_parity(
282 binding: &LegacyOperationBinding,
283 operation: &OperationSpec,
284) -> Result<(), CompatibilityError> {
285 let required = operation.required().iter().collect::<Vec<_>>();
286 let mut legacy_required = binding
287 .required_params()
288 .iter()
289 .map(String::as_str)
290 .collect::<Vec<_>>();
291 legacy_required.sort_unstable();
292 if required != legacy_required {
293 return contract_error(
294 "synapse-operations.json",
295 &format!("required field drift for {}", operation.name()),
296 );
297 }
298 let mut canonical_any = operation
299 .required_any()
300 .iter()
301 .map(|group| group.iter().map(str::to_owned).collect::<Vec<_>>())
302 .collect::<Vec<_>>();
303 canonical_any.sort();
304 let mut legacy_any = binding.required_any().to_vec();
305 for group in &mut legacy_any {
306 group.sort();
307 }
308 legacy_any.sort();
309 if canonical_any != legacy_any {
310 return contract_error(
311 "synapse-operations.json",
312 &format!("alternative field drift for {}", operation.name()),
313 );
314 }
315 Ok(())
316}
317
318fn parse<T: DeserializeOwned>(
319 artifact: &'static str,
320 input: &str,
321) -> Result<T, CompatibilityError> {
322 serde_json::from_str(input).map_err(|error| CompatibilityError::EmbeddedContract {
323 artifact,
324 message: error.to_string(),
325 })
326}
327
328pub(crate) fn contract_error<T>(
329 artifact: &'static str,
330 message: &str,
331) -> Result<T, CompatibilityError> {
332 Err(CompatibilityError::EmbeddedContract {
333 artifact,
334 message: message.to_owned(),
335 })
336}
337
338#[cfg(test)]
339#[path = "catalog_tests.rs"]
340mod tests;