Skip to main content

soothfast_spec/
dialect.rs

1//! What every generated dialect has in common, and how one is chosen.
2//!
3//! Generation is the same pipeline four times over — route shapes in, a
4//! document out, a compatibility diff against the merge-base — so the CLI
5//! holds a [`SpecKind`] and never a `match` over dialects. Adding a fifth
6//! means writing an emitter and a diff, not touching the command.
7
8use serde_json::Value;
9
10use crate::SpecKind;
11use crate::compat::Change;
12use crate::schema::route_sig::RouteShape;
13use crate::serialize;
14use crate::{asyncapi, graphql, mcp, openapi};
15
16/// Document metadata that no amount of code reading can derive.
17#[derive(Debug, Clone, Default)]
18pub struct Info {
19    pub title: String,
20    pub version: String,
21    pub description: Option<String>,
22    /// Server URLs. AsyncAPI splits these into host + protocol itself.
23    pub servers: Vec<String>,
24}
25
26/// One operation to emit, pairing spec identity with the inferred shape.
27#[derive(Debug, Clone)]
28pub struct Operation {
29    pub operation_id: String,
30    /// HTTP verb, or QUERY/MUTATION/SUBSCRIPTION/SEND/RECEIVE/TOOL —
31    /// whichever vocabulary the target dialect speaks.
32    pub method: String,
33    /// Route path, channel address, or field name.
34    pub path: String,
35    /// Usually the handler's doc comment.
36    pub summary: Option<String>,
37    pub shape: RouteShape,
38}
39
40/// A rendered document plus anything that had to be reconciled to build it.
41#[derive(Debug, Clone, Default)]
42pub struct Document {
43    pub value: Value,
44    /// Identity collisions. Never silently resolved: the last writer would
45    /// otherwise win and quietly change the contract.
46    pub conflicts: Vec<String>,
47    /// Shapes the dialect cannot express as precisely as the code states
48    /// them. Reported like extraction gaps, and like them never fatal.
49    pub notes: Vec<String>,
50}
51
52impl SpecKind {
53    /// Every dialect that can be generated, for help text and diagnostics.
54    pub const ALL: &'static [SpecKind] = &[
55        SpecKind::OpenApi,
56        SpecKind::AsyncApi,
57        SpecKind::GraphQl,
58        SpecKind::McpTools,
59    ];
60
61    /// The name used in `soothfast.toml` and in messages.
62    pub fn name(self) -> &'static str {
63        match self {
64            SpecKind::OpenApi => "openapi",
65            SpecKind::AsyncApi => "asyncapi",
66            SpecKind::GraphQl => "graphql",
67            SpecKind::McpTools => "mcp",
68        }
69    }
70
71    /// Parse a `dialect = "..."` value.
72    pub fn parse_name(s: &str) -> Option<SpecKind> {
73        SpecKind::ALL.iter().copied().find(|k| {
74            k.name() == s
75                // Spellings a reader would expect to work.
76                || matches!(
77                    (k, s),
78                    (SpecKind::GraphQl, "graphql-sdl" | "sdl")
79                        | (SpecKind::McpTools, "mcp-tools" | "tools")
80                        | (SpecKind::OpenApi, "openapi-3.1")
81                        | (SpecKind::AsyncApi, "asyncapi-3.0")
82                )
83        })
84    }
85
86    /// Guess the dialect of a spec file from its path.
87    ///
88    /// Generation needs a dialect before the file exists, so content sniffing
89    /// (which [`crate::sniff_kind`] does on the reconcile path) is not
90    /// available here. The guess is conservative and always overridable with
91    /// an explicit `dialect =` key: OpenAPI is the default because it is what
92    /// an unqualified `spec.yaml` almost always means.
93    pub fn for_path(path: &str) -> SpecKind {
94        let lower = path.to_ascii_lowercase();
95        let file = lower.rsplit('/').next().unwrap_or(&lower);
96        if file.ends_with(".graphql") || file.ends_with(".graphqls") || file.ends_with(".sdl") {
97            return SpecKind::GraphQl;
98        }
99        if file.contains("asyncapi") {
100            return SpecKind::AsyncApi;
101        }
102        if file.contains("mcp") || file.contains("tools") {
103            return SpecKind::McpTools;
104        }
105        SpecKind::OpenApi
106    }
107
108    /// Assemble a document from inferred route shapes.
109    pub fn document(self, info: &Info, ops: &[Operation]) -> Document {
110        match self {
111            SpecKind::OpenApi => openapi::document(info, ops),
112            SpecKind::AsyncApi => asyncapi::document(info, ops),
113            SpecKind::GraphQl => graphql::document(info, ops),
114            SpecKind::McpTools => mcp::document(info, ops),
115        }
116    }
117
118    /// Serialize a document for writing to `path`.
119    ///
120    /// GraphQL ignores the extension because SDL is its only serialization;
121    /// the rest honour `.json` and default to the dialect's usual form.
122    pub fn render(self, path: &str, value: &Value) -> String {
123        if self == SpecKind::GraphQl {
124            return graphql::to_sdl(value);
125        }
126        let json = match path.rsplit('.').next() {
127            Some(ext) if ext.eq_ignore_ascii_case("json") => true,
128            Some(ext) if ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml") => {
129                false
130            }
131            // MCP tool manifests are JSON everywhere they are consumed.
132            _ => self == SpecKind::McpTools,
133        };
134        if json {
135            serialize::to_json(value)
136        } else {
137            serialize::to_yaml(value)
138        }
139    }
140
141    /// Parse a committed spec file back into its generated document.
142    ///
143    /// Round-trips the result through [`SpecKind::render`] and fails on any
144    /// drift, so a stale or hand-edited file can never be silently diffed
145    /// as the base.
146    pub fn parse(self, path: &str, text: &str) -> Result<Value, String> {
147        let value = match self {
148            SpecKind::GraphQl => crate::graphql::from_sdl(text)?,
149            _ => serialize::from_text(text)?,
150        };
151        if self.render(path, &value).trim_end() != text.trim_end() {
152            return Err(format!(
153                "{path}: committed spec does not re-render byte-identically — \
154                 regenerate it, or run the gate without --from-committed"
155            ));
156        }
157        Ok(value)
158    }
159
160    /// Compare two revisions of a document for consumer compatibility.
161    pub fn diff(self, old: &Value, new: &Value) -> Vec<Change> {
162        match self {
163            SpecKind::OpenApi => openapi::diff::diff(old, new),
164            SpecKind::AsyncApi => asyncapi::diff::diff(old, new),
165            SpecKind::GraphQl => graphql::diff::diff(old, new),
166            SpecKind::McpTools => mcp::diff(old, new),
167        }
168    }
169
170    /// The document a spec file that does not yet exist compares as, so that
171    /// everything in a newly added file reads as additive by construction.
172    pub fn empty(self) -> Value {
173        match self {
174            SpecKind::OpenApi => serde_json::json!({ "openapi": "3.1.0", "paths": {} }),
175            SpecKind::AsyncApi => {
176                serde_json::json!({ "asyncapi": "3.0.0", "channels": {}, "operations": {} })
177            }
178            SpecKind::GraphQl => serde_json::json!({ "types": {}, "roots": {} }),
179            SpecKind::McpTools => serde_json::json!({ "tools": [] }),
180        }
181    }
182
183    /// Operation and named-schema counts, for the one-line generation summary.
184    pub fn summarize(self, value: &Value) -> (usize, usize) {
185        let count = |v: &Value| v.as_object().map(|o| o.len()).unwrap_or(0);
186        match self {
187            SpecKind::OpenApi => (
188                value["paths"]
189                    .as_object()
190                    .map(|p| p.values().map(count).sum())
191                    .unwrap_or(0),
192                count(&value["components"]["schemas"]),
193            ),
194            SpecKind::AsyncApi => (
195                count(&value["operations"]),
196                count(&value["components"]["schemas"]),
197            ),
198            SpecKind::GraphQl => (
199                value["roots"]
200                    .as_object()
201                    .map(|r| r.values().map(count).sum())
202                    .unwrap_or(0),
203                count(&value["types"]),
204            ),
205            SpecKind::McpTools => (
206                value["tools"].as_array().map(|a| a.len()).unwrap_or(0),
207                value["tools"]
208                    .as_array()
209                    .map(|tools| {
210                        tools
211                            .iter()
212                            .flat_map(|t| [&t["inputSchema"]["$defs"], &t["outputSchema"]["$defs"]])
213                            .filter_map(|d| d.as_object())
214                            .flat_map(|d| d.keys().cloned())
215                            .collect::<std::collections::BTreeSet<_>>()
216                            .len()
217                    })
218                    .unwrap_or(0),
219            ),
220        }
221    }
222}
223
224/// Report a method a dialect has no vocabulary for, rather than guessing one.
225pub(crate) fn unknown_method(op: &Operation, dialect: &str, expected: &str) -> String {
226    format!(
227        "operation `{}` declares method `{}`, which {dialect} has no meaning for \
228         (expected one of {expected}) — fix the #[route] method or point this \
229         route at a spec file of the matching dialect",
230        op.operation_id, op.method
231    )
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn a_bare_spec_file_is_openapi() {
240        assert_eq!(SpecKind::for_path("specs/spec.yaml"), SpecKind::OpenApi);
241        assert_eq!(SpecKind::for_path("openapi.json"), SpecKind::OpenApi);
242    }
243
244    #[test]
245    fn extensions_and_filenames_name_the_other_dialects() {
246        assert_eq!(SpecKind::for_path("schema.graphql"), SpecKind::GraphQl);
247        assert_eq!(SpecKind::for_path("a/b/schema.sdl"), SpecKind::GraphQl);
248        assert_eq!(
249            SpecKind::for_path("specs/asyncapi.yaml"),
250            SpecKind::AsyncApi
251        );
252        assert_eq!(
253            SpecKind::for_path("specs/mcp-tools.json"),
254            SpecKind::McpTools
255        );
256        assert_eq!(SpecKind::for_path("tools.json"), SpecKind::McpTools);
257    }
258
259    #[test]
260    fn a_directory_name_does_not_decide_the_dialect() {
261        // `graphql/` as a directory is common; the file is what counts.
262        assert_eq!(SpecKind::for_path("graphql/spec.yaml"), SpecKind::OpenApi);
263    }
264
265    #[test]
266    fn parse_round_trips_a_rendered_document() {
267        let doc = serde_json::json!({
268            "openapi": "3.1.0",
269            "info": { "title": "Items", "version": "1.0" },
270            "paths": { "/items": { "get": {
271                "operationId": "listItems",
272                "responses": { "200": { "description": "OK" } },
273            } } },
274        });
275        for path in ["openapi.yaml", "openapi.json"] {
276            let rendered = SpecKind::OpenApi.render(path, &doc);
277            let parsed = SpecKind::OpenApi.parse(path, &rendered).expect("parses");
278            assert_eq!(SpecKind::OpenApi.render(path, &parsed), rendered);
279        }
280    }
281
282    #[test]
283    fn parse_rejects_text_that_does_not_re_render_identically() {
284        // Valid YAML, but hand-ordered keys the renderer would sort.
285        let err = SpecKind::OpenApi
286            .parse("openapi.yaml", "zebra: 1\nopenapi: 3.1.0\n")
287            .unwrap_err();
288        assert!(err.contains("byte-identically"), "{err}");
289    }
290
291    #[test]
292    fn parse_reads_generated_sdl() {
293        let doc = serde_json::json!({
294            "types": { "JSON": { "kind": "scalar" } },
295            "roots": { "Query": { "ping": { "type": "String!" } } },
296        });
297        let rendered = SpecKind::GraphQl.render("schema.graphql", &doc);
298        let parsed = SpecKind::GraphQl
299            .parse("schema.graphql", &rendered)
300            .expect("parses");
301        assert_eq!(parsed["roots"]["Query"]["ping"]["type"], "String!");
302    }
303
304    #[test]
305    fn dialect_names_round_trip() {
306        for k in SpecKind::ALL {
307            assert_eq!(SpecKind::parse_name(k.name()), Some(*k));
308        }
309        assert_eq!(SpecKind::parse_name("sdl"), Some(SpecKind::GraphQl));
310        assert_eq!(SpecKind::parse_name("nonsense"), None);
311    }
312
313    #[test]
314    fn the_extension_picks_the_serialization() {
315        let v = serde_json::json!({ "openapi": "3.1.0" });
316        assert!(SpecKind::OpenApi.render("a/spec.json", &v).starts_with('{'));
317        assert!(
318            SpecKind::OpenApi
319                .render("a/spec.yaml", &v)
320                .starts_with("openapi:")
321        );
322        // MCP defaults to JSON when the extension says nothing.
323        let tools = serde_json::json!({ "tools": [] });
324        assert!(SpecKind::McpTools.render("tools", &tools).starts_with('{'));
325        assert!(
326            SpecKind::McpTools
327                .render("tools.yaml", &tools)
328                .starts_with("tools:")
329        );
330    }
331
332    #[test]
333    fn an_empty_document_diffs_as_all_additive() {
334        for kind in SpecKind::ALL {
335            let empty = kind.empty();
336            let changes = kind.diff(&empty, &empty);
337            assert!(changes.is_empty(), "{kind:?} got {changes:?}");
338        }
339    }
340}