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