Skip to main content

shaperail_codegen/
parser.rs

1use shaperail_core::ResourceDefinition;
2
3/// Errors that can occur during YAML parsing.
4#[derive(Debug, thiserror::Error)]
5pub enum ParseError {
6    #[error("{0}")]
7    Yaml(#[from] serde_yaml::Error),
8
9    #[error("{0}")]
10    Io(#[from] std::io::Error),
11
12    #[error("{0}")]
13    ConfigInterpolation(String),
14
15    /// A removed field type was used. Provides a friendly migration message.
16    #[error("[{code}] {message}")]
17    RemovedType {
18        code: &'static str,
19        message: &'static str,
20    },
21
22    #[error("{file}: {source}")]
23    Context {
24        file: String,
25        #[source]
26        source: Box<ParseError>,
27    },
28}
29
30/// Parse a resource definition from a YAML string using the default
31/// (serde_yaml) parser. Used by tests and tooling that already have the
32/// YAML in memory. Endpoint defaults are NOT applied; use [`parse_resource`]
33/// if you need them.
34pub fn parse_resource_str(yaml: &str) -> Result<ResourceDefinition, ParseError> {
35    if yaml.contains("type: bigint") {
36        return Err(ParseError::RemovedType {
37            code: "E_BIGINT_REMOVED",
38            message:
39                "type 'bigint' was removed in v0.13.0 — use 'integer' (now 64-bit by default).",
40        });
41    }
42    Ok(serde_yaml::from_str(yaml)?)
43}
44
45/// Parse a YAML string into a `ResourceDefinition`.
46///
47/// After parsing, convention-based endpoint defaults are applied: for known
48/// endpoint names (list, get, create, update, delete), `method` and `path`
49/// are inferred from the resource name if not explicitly provided.
50pub fn parse_resource(yaml: &str) -> Result<ResourceDefinition, ParseError> {
51    // Pre-check: detect removed `bigint` field type before serde deserialization
52    // so we can emit a friendly error instead of an opaque "unknown variant" message.
53    if yaml.contains("type: bigint") {
54        return Err(ParseError::RemovedType {
55            code: "E_BIGINT_REMOVED",
56            message:
57                "type 'bigint' was removed in v0.13.0 — use 'integer' (now 64-bit by default).",
58        });
59    }
60    let mut resource: ResourceDefinition = serde_yaml::from_str(yaml)?;
61    shaperail_core::apply_endpoint_defaults(&mut resource);
62    Ok(resource)
63}
64
65/// Parse a resource YAML file from disk.
66///
67/// Wraps parse errors with the filename for clearer diagnostics.
68pub fn parse_resource_file(path: &std::path::Path) -> Result<ResourceDefinition, ParseError> {
69    let content = std::fs::read_to_string(path)?;
70    parse_resource(&content).map_err(|e| ParseError::Context {
71        file: path.display().to_string(),
72        source: Box::new(e),
73    })
74}
75
76/// Parse and diagnose a resource file. When the `saphyr-spans` feature is
77/// enabled and the file parses cleanly under saphyr, diagnostics carry
78/// source spans for root-level codes. Otherwise spans are absent.
79///
80/// Returns the parsed ResourceDefinition along with the diagnostics so
81/// callers don't have to re-parse the file for additional analysis.
82pub fn diagnose_file(
83    path: &std::path::Path,
84) -> Result<
85    (
86        shaperail_core::ResourceDefinition,
87        Vec<crate::diagnostics::Diagnostic>,
88    ),
89    ParseError,
90> {
91    let yaml = std::fs::read_to_string(path).map_err(ParseError::from)?;
92
93    #[cfg(feature = "saphyr-spans")]
94    {
95        match crate::parser_saphyr::parse_with_spans_in_file(&yaml, path.to_path_buf()) {
96            Ok((rd, spans)) => {
97                let diags = crate::diagnostics::diagnose_resource_with_spans(&rd, &spans);
98                return Ok((rd, diags));
99            }
100            Err(_saphyr_err) => {
101                // Fall through to serde_yaml. If both parsers fail, the
102                // serde_yaml error is what's surfaced — usually more
103                // readable. The saphyr-side message is dropped, which is
104                // a deliberate trade-off; revisit if span-parser failures
105                // need user-visible diagnostics in their own right.
106            }
107        }
108    }
109
110    let rd: shaperail_core::ResourceDefinition =
111        serde_yaml::from_str(&yaml).map_err(ParseError::from)?;
112    let diags = crate::diagnostics::diagnose_resource(&rd);
113    Ok((rd, diags))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn parse_minimal_resource() {
122        let yaml = r#"
123resource: tags
124version: 1
125schema:
126  id: { type: uuid, primary: true, generated: true }
127  name: { type: string, required: true }
128"#;
129        let rd = parse_resource(yaml).unwrap();
130        assert_eq!(rd.resource, "tags");
131        assert_eq!(rd.version, 1);
132        assert_eq!(rd.schema.len(), 2);
133        assert!(rd.endpoints.is_none());
134        assert!(rd.relations.is_none());
135        assert!(rd.indexes.is_none());
136    }
137
138    #[test]
139    fn parse_full_users_resource() {
140        let yaml = include_str!("../../resources/users.yaml");
141        let rd = parse_resource(yaml).unwrap();
142        assert_eq!(rd.resource, "users");
143        assert_eq!(rd.version, 1);
144        assert_eq!(rd.schema.len(), 10);
145        assert!(rd.endpoints.is_some());
146        assert!(rd.relations.is_some());
147        assert!(rd.indexes.is_some());
148    }
149
150    #[test]
151    fn parse_error_invalid_yaml() {
152        let yaml = "not: [valid: yaml: here";
153        let err = parse_resource(yaml).unwrap_err();
154        assert!(matches!(err, ParseError::Yaml(_)));
155    }
156
157    #[test]
158    fn parse_error_missing_resource_key() {
159        let yaml = r#"
160version: 1
161schema:
162  id: { type: uuid }
163"#;
164        let err = parse_resource(yaml).unwrap_err();
165        assert!(err.to_string().contains("missing field"));
166    }
167
168    #[test]
169    fn parse_error_unknown_top_level_key() {
170        let yaml = r#"
171resource: tags
172version: 1
173schema:
174  id: { type: uuid, primary: true, generated: true }
175unknown: true
176"#;
177        let err = parse_resource(yaml).unwrap_err();
178        assert!(err.to_string().contains("unknown field"));
179        assert!(err.to_string().contains("unknown"));
180    }
181
182    #[test]
183    fn parse_error_unknown_field_key() {
184        let yaml = r#"
185resource: tags
186version: 1
187schema:
188  id: { type: uuid, primary: true, generated: true, searchable: true }
189"#;
190        let err = parse_resource(yaml).unwrap_err();
191        assert!(err.to_string().contains("unknown field"));
192        assert!(err.to_string().contains("searchable"));
193    }
194
195    #[test]
196    fn parse_resource_with_db_key() {
197        let yaml = r#"
198resource: events
199version: 1
200db: analytics
201schema:
202  id: { type: uuid, primary: true, generated: true }
203  name: { type: string, required: true }
204"#;
205        let rd = parse_resource(yaml).unwrap();
206        assert_eq!(rd.resource, "events");
207        assert_eq!(rd.db.as_deref(), Some("analytics"));
208    }
209
210    #[test]
211    fn parse_resource_file_includes_filename_in_error() {
212        let path = std::path::Path::new("nonexistent/resource.yaml");
213        let err = parse_resource_file(path).unwrap_err();
214        // IO error for missing file — no Context wrapper needed
215        assert!(matches!(err, ParseError::Io(_)));
216    }
217
218    #[test]
219    fn parse_resource_file_context_wraps_yaml_error() {
220        let dir = tempfile::tempdir().unwrap();
221        let path = dir.path().join("bad.yaml");
222        std::fs::write(&path, "resource: test\nunknown_key: true\n").unwrap();
223
224        let err = parse_resource_file(&path).unwrap_err();
225        let msg = err.to_string();
226        // Error message should contain the filename
227        assert!(
228            msg.contains("bad.yaml"),
229            "Expected filename in error, got: {msg}"
230        );
231        // And also the actual parse error
232        assert!(
233            msg.contains("unknown field"),
234            "Expected parse error detail, got: {msg}"
235        );
236    }
237}