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    /// Compare two revisions of a document for consumer compatibility.
142    pub fn diff(self, old: &Value, new: &Value) -> Vec<Change> {
143        match self {
144            SpecKind::OpenApi => openapi::diff::diff(old, new),
145            SpecKind::AsyncApi => asyncapi::diff::diff(old, new),
146            SpecKind::GraphQl => graphql::diff::diff(old, new),
147            SpecKind::McpTools => mcp::diff(old, new),
148        }
149    }
150
151    /// The document a spec file that does not yet exist compares as, so that
152    /// everything in a newly added file reads as additive by construction.
153    pub fn empty(self) -> Value {
154        match self {
155            SpecKind::OpenApi => serde_json::json!({ "openapi": "3.1.0", "paths": {} }),
156            SpecKind::AsyncApi => {
157                serde_json::json!({ "asyncapi": "3.0.0", "channels": {}, "operations": {} })
158            }
159            SpecKind::GraphQl => serde_json::json!({ "types": {}, "roots": {} }),
160            SpecKind::McpTools => serde_json::json!({ "tools": [] }),
161        }
162    }
163
164    /// Operation and named-schema counts, for the one-line generation summary.
165    pub fn summarize(self, value: &Value) -> (usize, usize) {
166        let count = |v: &Value| v.as_object().map(|o| o.len()).unwrap_or(0);
167        match self {
168            SpecKind::OpenApi => (
169                value["paths"]
170                    .as_object()
171                    .map(|p| p.values().map(count).sum())
172                    .unwrap_or(0),
173                count(&value["components"]["schemas"]),
174            ),
175            SpecKind::AsyncApi => (
176                count(&value["operations"]),
177                count(&value["components"]["schemas"]),
178            ),
179            SpecKind::GraphQl => (
180                value["roots"]
181                    .as_object()
182                    .map(|r| r.values().map(count).sum())
183                    .unwrap_or(0),
184                count(&value["types"]),
185            ),
186            SpecKind::McpTools => (
187                value["tools"].as_array().map(|a| a.len()).unwrap_or(0),
188                value["tools"]
189                    .as_array()
190                    .map(|tools| {
191                        tools
192                            .iter()
193                            .flat_map(|t| [&t["inputSchema"]["$defs"], &t["outputSchema"]["$defs"]])
194                            .filter_map(|d| d.as_object())
195                            .flat_map(|d| d.keys().cloned())
196                            .collect::<std::collections::BTreeSet<_>>()
197                            .len()
198                    })
199                    .unwrap_or(0),
200            ),
201        }
202    }
203}
204
205/// Report a method a dialect has no vocabulary for, rather than guessing one.
206pub(crate) fn unknown_method(op: &Operation, dialect: &str, expected: &str) -> String {
207    format!(
208        "operation `{}` declares method `{}`, which {dialect} has no meaning for \
209         (expected one of {expected}) — fix the #[route] method or point this \
210         route at a spec file of the matching dialect",
211        op.operation_id, op.method
212    )
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn a_bare_spec_file_is_openapi() {
221        assert_eq!(SpecKind::for_path("specs/spec.yaml"), SpecKind::OpenApi);
222        assert_eq!(SpecKind::for_path("openapi.json"), SpecKind::OpenApi);
223    }
224
225    #[test]
226    fn extensions_and_filenames_name_the_other_dialects() {
227        assert_eq!(SpecKind::for_path("schema.graphql"), SpecKind::GraphQl);
228        assert_eq!(SpecKind::for_path("a/b/schema.sdl"), SpecKind::GraphQl);
229        assert_eq!(
230            SpecKind::for_path("specs/asyncapi.yaml"),
231            SpecKind::AsyncApi
232        );
233        assert_eq!(
234            SpecKind::for_path("specs/mcp-tools.json"),
235            SpecKind::McpTools
236        );
237        assert_eq!(SpecKind::for_path("tools.json"), SpecKind::McpTools);
238    }
239
240    #[test]
241    fn a_directory_name_does_not_decide_the_dialect() {
242        // `graphql/` as a directory is common; the file is what counts.
243        assert_eq!(SpecKind::for_path("graphql/spec.yaml"), SpecKind::OpenApi);
244    }
245
246    #[test]
247    fn dialect_names_round_trip() {
248        for k in SpecKind::ALL {
249            assert_eq!(SpecKind::parse_name(k.name()), Some(*k));
250        }
251        assert_eq!(SpecKind::parse_name("sdl"), Some(SpecKind::GraphQl));
252        assert_eq!(SpecKind::parse_name("nonsense"), None);
253    }
254
255    #[test]
256    fn the_extension_picks_the_serialization() {
257        let v = serde_json::json!({ "openapi": "3.1.0" });
258        assert!(SpecKind::OpenApi.render("a/spec.json", &v).starts_with('{'));
259        assert!(
260            SpecKind::OpenApi
261                .render("a/spec.yaml", &v)
262                .starts_with("openapi:")
263        );
264        // MCP defaults to JSON when the extension says nothing.
265        let tools = serde_json::json!({ "tools": [] });
266        assert!(SpecKind::McpTools.render("tools", &tools).starts_with('{'));
267        assert!(
268            SpecKind::McpTools
269                .render("tools.yaml", &tools)
270                .starts_with("tools:")
271        );
272    }
273
274    #[test]
275    fn an_empty_document_diffs_as_all_additive() {
276        for kind in SpecKind::ALL {
277            let empty = kind.empty();
278            let changes = kind.diff(&empty, &empty);
279            assert!(changes.is_empty(), "{kind:?} got {changes:?}");
280        }
281    }
282}