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 YAML string into a `ResourceDefinition`.
31///
32/// After parsing, convention-based endpoint defaults are applied: for known
33/// endpoint names (list, get, create, update, delete), `method` and `path`
34/// are inferred from the resource name if not explicitly provided.
35pub fn parse_resource(yaml: &str) -> Result<ResourceDefinition, ParseError> {
36    // Pre-check: detect removed `bigint` field type before serde deserialization
37    // so we can emit a friendly error instead of an opaque "unknown variant" message.
38    if yaml.contains("type: bigint") {
39        return Err(ParseError::RemovedType {
40            code: "E_BIGINT_REMOVED",
41            message:
42                "type 'bigint' was removed in v0.13.0 — use 'integer' (now 64-bit by default).",
43        });
44    }
45    let mut resource: ResourceDefinition = serde_yaml::from_str(yaml)?;
46    shaperail_core::apply_endpoint_defaults(&mut resource);
47    Ok(resource)
48}
49
50/// Parse a resource YAML file from disk.
51///
52/// Wraps parse errors with the filename for clearer diagnostics.
53pub fn parse_resource_file(path: &std::path::Path) -> Result<ResourceDefinition, ParseError> {
54    let content = std::fs::read_to_string(path)?;
55    parse_resource(&content).map_err(|e| ParseError::Context {
56        file: path.display().to_string(),
57        source: Box::new(e),
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn parse_minimal_resource() {
67        let yaml = r#"
68resource: tags
69version: 1
70schema:
71  id: { type: uuid, primary: true, generated: true }
72  name: { type: string, required: true }
73"#;
74        let rd = parse_resource(yaml).unwrap();
75        assert_eq!(rd.resource, "tags");
76        assert_eq!(rd.version, 1);
77        assert_eq!(rd.schema.len(), 2);
78        assert!(rd.endpoints.is_none());
79        assert!(rd.relations.is_none());
80        assert!(rd.indexes.is_none());
81    }
82
83    #[test]
84    fn parse_full_users_resource() {
85        let yaml = include_str!("../../resources/users.yaml");
86        let rd = parse_resource(yaml).unwrap();
87        assert_eq!(rd.resource, "users");
88        assert_eq!(rd.version, 1);
89        assert_eq!(rd.schema.len(), 10);
90        assert!(rd.endpoints.is_some());
91        assert!(rd.relations.is_some());
92        assert!(rd.indexes.is_some());
93    }
94
95    #[test]
96    fn parse_error_invalid_yaml() {
97        let yaml = "not: [valid: yaml: here";
98        let err = parse_resource(yaml).unwrap_err();
99        assert!(matches!(err, ParseError::Yaml(_)));
100    }
101
102    #[test]
103    fn parse_error_missing_resource_key() {
104        let yaml = r#"
105version: 1
106schema:
107  id: { type: uuid }
108"#;
109        let err = parse_resource(yaml).unwrap_err();
110        assert!(err.to_string().contains("missing field"));
111    }
112
113    #[test]
114    fn parse_error_unknown_top_level_key() {
115        let yaml = r#"
116resource: tags
117version: 1
118schema:
119  id: { type: uuid, primary: true, generated: true }
120unknown: true
121"#;
122        let err = parse_resource(yaml).unwrap_err();
123        assert!(err.to_string().contains("unknown field"));
124        assert!(err.to_string().contains("unknown"));
125    }
126
127    #[test]
128    fn parse_error_unknown_field_key() {
129        let yaml = r#"
130resource: tags
131version: 1
132schema:
133  id: { type: uuid, primary: true, generated: true, searchable: true }
134"#;
135        let err = parse_resource(yaml).unwrap_err();
136        assert!(err.to_string().contains("unknown field"));
137        assert!(err.to_string().contains("searchable"));
138    }
139
140    #[test]
141    fn parse_resource_with_db_key() {
142        let yaml = r#"
143resource: events
144version: 1
145db: analytics
146schema:
147  id: { type: uuid, primary: true, generated: true }
148  name: { type: string, required: true }
149"#;
150        let rd = parse_resource(yaml).unwrap();
151        assert_eq!(rd.resource, "events");
152        assert_eq!(rd.db.as_deref(), Some("analytics"));
153    }
154
155    #[test]
156    fn parse_resource_file_includes_filename_in_error() {
157        let path = std::path::Path::new("nonexistent/resource.yaml");
158        let err = parse_resource_file(path).unwrap_err();
159        // IO error for missing file — no Context wrapper needed
160        assert!(matches!(err, ParseError::Io(_)));
161    }
162
163    #[test]
164    fn parse_resource_file_context_wraps_yaml_error() {
165        let dir = tempfile::tempdir().unwrap();
166        let path = dir.path().join("bad.yaml");
167        std::fs::write(&path, "resource: test\nunknown_key: true\n").unwrap();
168
169        let err = parse_resource_file(&path).unwrap_err();
170        let msg = err.to_string();
171        // Error message should contain the filename
172        assert!(
173            msg.contains("bad.yaml"),
174            "Expected filename in error, got: {msg}"
175        );
176        // And also the actual parse error
177        assert!(
178            msg.contains("unknown field"),
179            "Expected parse error detail, got: {msg}"
180        );
181    }
182}