1use reed_compiler::Schema;
47use std::path::{Path, PathBuf};
48
49pub mod codegen;
50mod sync;
51
52pub fn build(schema_dir: impl AsRef<Path>) -> Result<(), BuildError> {
78 let out_dir = std::env::var("OUT_DIR").map_err(|source| BuildError::Environment {
79 variable: "OUT_DIR",
80 source,
81 })?;
82 let manifest_dir =
83 std::env::var("CARGO_MANIFEST_DIR").map_err(|source| BuildError::Environment {
84 variable: "CARGO_MANIFEST_DIR",
85 source,
86 })?;
87 build_at(
88 schema_dir.as_ref(),
89 Path::new(&out_dir),
90 Path::new(&manifest_dir),
91 )
92}
93
94fn build_at(schema_dir: &Path, out_dir: &Path, manifest_dir: &Path) -> Result<(), BuildError> {
95 println!("cargo::rerun-if-changed={}", schema_dir.display());
96 let mut paths = schema_paths(schema_dir)?;
97
98 paths.sort();
100
101 let builder = paths
102 .into_iter()
103 .try_fold(Schema::builder(), |builder, path| {
104 println!("cargo::rerun-if-changed={}", path.display());
105 let source = std::fs::read_to_string(&path).map_err(|source| BuildError::Io {
106 path: path.clone(),
107 source,
108 })?;
109
110 Ok::<_, BuildError>(builder.parse(source, path))
111 })?;
112
113 let schema = builder
114 .build()
115 .map_err(|error| BuildError::Schema(error.errors))?
116 .validate()
117 .map_err(|error| BuildError::Schema(error.errors))?;
118 let generated = codegen::generate(&schema).map_err(BuildError::Codegen)?;
119 let output = out_dir.join("necrassrs.rs");
120 std::fs::write(&output, generated).map_err(|source| BuildError::Io {
121 path: output,
122 source,
123 })?;
124
125 let resolver_path = manifest_dir.join("src/resolvers.rs");
126
127 sync::synchronize(&schema, &resolver_path)
128}
129
130fn schema_paths(directory: &Path) -> Result<Vec<PathBuf>, BuildError> {
131 std::fs::read_dir(directory)
132 .map_err(|source| BuildError::Io {
133 path: directory.to_path_buf(),
134 source,
135 })?
136 .try_fold(Vec::new(), |mut paths, entry| {
137 let entry = entry.map_err(|source| BuildError::Io {
138 path: directory.to_path_buf(),
139 source,
140 })?;
141 let path = entry.path();
142 let file_type = entry.file_type().map_err(|source| BuildError::Io {
144 path: path.clone(),
145 source,
146 })?;
147
148 if file_type.is_dir() {
149 paths.extend(schema_paths(&path)?);
150 } else if file_type.is_file()
151 && path
152 .extension()
153 .is_some_and(|extension| extension == "graphql")
154 {
155 paths.push(path);
156 }
157
158 Ok(paths)
159 })
160}
161
162#[derive(Debug)]
164pub enum BuildError {
165 Environment {
167 variable: &'static str,
169 source: std::env::VarError,
171 },
172 Io {
174 path: std::path::PathBuf,
176 source: std::io::Error,
178 },
179 Schema(reed_compiler::validation::DiagnosticList),
181 Codegen(codegen::CodegenError),
183 ResolverSource {
185 path: PathBuf,
187 source: syn::Error,
189 },
190}
191
192impl std::fmt::Display for BuildError {
193 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 match self {
195 Self::Environment { variable, source } => {
196 write!(
197 formatter,
198 "Could not read environment variable {variable}: {source}"
199 )
200 }
201 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
202 Self::Schema(diagnostics) => std::fmt::Display::fmt(diagnostics, formatter),
203 Self::Codegen(error) => std::fmt::Display::fmt(error, formatter),
204 Self::ResolverSource { path, source } => {
205 write!(formatter, "{}: {source}", path.display())
206 }
207 }
208 }
209}
210
211impl std::error::Error for BuildError {
212 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
213 match self {
214 Self::Environment { source, .. } => Some(source),
215 Self::Io { source, .. } => Some(source),
216 Self::Schema(_) => None,
217 Self::Codegen(error) => Some(error),
218 Self::ResolverSource { source, .. } => Some(source),
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::sync::atomic::{AtomicUsize, Ordering};
227
228 struct BuildDir(PathBuf);
229
230 impl BuildDir {
231 fn new() -> Self {
232 static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
233 let path = std::env::temp_dir().join(format!(
234 "necrassrs-build-unit-{}-{}",
235 std::process::id(),
236 NEXT_ID.fetch_add(1, Ordering::Relaxed),
237 ));
238 std::fs::create_dir(&path).unwrap();
239 Self(path)
240 }
241
242 fn build(&self) -> Result<(), BuildError> {
243 super::build_at(&self.0.join("schema"), &self.0.join("out"), &self.0)
244 }
245 }
246
247 impl Drop for BuildDir {
248 fn drop(&mut self) {
249 std::fs::remove_dir_all(&self.0).unwrap();
250 }
251 }
252
253 #[test]
254 fn discovers_nested_graphql_files_without_following_symlinks() {
255 let directory = BuildDir::new();
256 let schema = directory.0.join("schema");
257 std::fs::create_dir_all(schema.join("nested")).unwrap();
258 std::fs::write(schema.join("b.graphql"), "type Query { b: String! }").unwrap();
259 std::fs::write(
260 schema.join("nested/a.graphql"),
261 "extend type Query { a: String! }",
262 )
263 .unwrap();
264 std::fs::write(schema.join("ignore.txt"), "ignored").unwrap();
265 #[cfg(unix)]
266 {
267 std::os::unix::fs::symlink(schema.join("b.graphql"), schema.join("link.graphql"))
268 .unwrap();
269 std::os::unix::fs::symlink(schema.join("nested"), schema.join("linked_dir")).unwrap();
270 }
271
272 let mut paths = schema_paths(&schema).unwrap();
273 paths.sort();
274 assert_eq!(
275 paths,
276 [schema.join("b.graphql"), schema.join("nested/a.graphql")]
277 );
278 assert!(matches!(
279 schema_paths(&schema.join("missing")),
280 Err(BuildError::Io { .. })
281 ));
282 }
283
284 #[test]
285 fn build_generates_contract_and_editable_resolvers() {
286 let directory = BuildDir::new();
287 std::fs::create_dir_all(directory.0.join("schema/nested")).unwrap();
288 std::fs::create_dir(directory.0.join("out")).unwrap();
289 std::fs::create_dir(directory.0.join("src")).unwrap();
290 std::fs::write(
291 directory.0.join("schema/query.graphql"),
292 "type Query { hello: String! }",
293 )
294 .unwrap();
295 std::fs::write(
296 directory.0.join("schema/nested/extra.graphql"),
297 "extend type Query { extra: String! }",
298 )
299 .unwrap();
300
301 directory.build().unwrap();
302 let generated = std::fs::read_to_string(directory.0.join("out/necrassrs.rs")).unwrap();
303 let resolvers = std::fs::read_to_string(directory.0.join("src/resolvers.rs")).unwrap();
304 assert!(generated.contains("hello"));
305 assert!(generated.contains("extra"));
306 assert!(resolvers.contains("fields::Query::hello"));
307 assert!(resolvers.contains("fields::Query::extra"));
308
309 directory.build().unwrap();
310 assert_eq!(
311 std::fs::read_to_string(directory.0.join("src/resolvers.rs")).unwrap(),
312 resolvers
313 );
314 }
315
316 #[test]
317 fn validation_errors_leave_existing_source_untouched() {
318 let directory = BuildDir::new();
319 std::fs::create_dir(directory.0.join("schema")).unwrap();
320 std::fs::create_dir(directory.0.join("out")).unwrap();
321 std::fs::create_dir(directory.0.join("src")).unwrap();
322 let sdl = directory.0.join("schema/query.graphql");
323 let resolver = directory.0.join("src/resolvers.rs");
324 std::fs::write(&resolver, "user code").unwrap();
325 std::fs::write(&sdl, "type Query { hello(: String!): String! }").unwrap();
326
327 assert!(matches!(directory.build(), Err(BuildError::Schema(_))));
328 assert_eq!(std::fs::read_to_string(&resolver).unwrap(), "user code");
329 }
330
331 #[test]
332 fn reports_missing_inputs_and_output_destinations() {
333 let directory = BuildDir::new();
334 assert!(matches!(directory.build(), Err(BuildError::Io { .. })));
335
336 std::fs::create_dir(directory.0.join("schema")).unwrap();
337 std::fs::write(
338 directory.0.join("schema/query.graphql"),
339 "type Query { hello: String! }",
340 )
341 .unwrap();
342 assert!(matches!(directory.build(), Err(BuildError::Io { .. })));
343
344 std::fs::create_dir(directory.0.join("out")).unwrap();
345 assert!(matches!(directory.build(), Err(BuildError::Io { .. })));
346 }
347
348 #[test]
349 fn build_errors_expose_their_causes_and_locations() {
350 use std::error::Error;
351
352 let path = PathBuf::from("schema/query.graphql");
353 let errors = [
354 BuildError::Environment {
355 variable: "OUT_DIR",
356 source: std::env::VarError::NotPresent,
357 },
358 BuildError::Io {
359 path: path.clone(),
360 source: std::io::Error::from(std::io::ErrorKind::NotFound),
361 },
362 BuildError::ResolverSource {
363 path,
364 source: syn::Error::new(proc_macro2::Span::call_site(), "invalid resolver"),
365 },
366 ];
367 for (error, expected) in
368 errors
369 .into_iter()
370 .zip(["OUT_DIR", "schema/query.graphql", "invalid resolver"])
371 {
372 assert!(error.to_string().contains(expected));
373 assert!(error.source().is_some());
374 }
375
376 let invalid = Schema::parse_and_validate(
377 "type Query { hello(: String!): String! }",
378 "schema.graphql",
379 )
380 .unwrap_err();
381 let schema_error = BuildError::Schema(invalid.errors);
382 assert!(!schema_error.to_string().is_empty());
383 assert!(schema_error.source().is_none());
384
385 let valid =
386 Schema::parse_and_validate("type Query { query: Query }", "schema.graphql").unwrap();
387 let codegen_error = BuildError::Codegen(codegen::generate(&valid).unwrap_err());
388 assert!(
389 codegen_error
390 .to_string()
391 .contains("Unsupported return type at Query.query")
392 );
393 assert!(codegen_error.source().is_some());
394 }
395}