Skip to main content

necrassrs_build/
lib.rs

1//! SDL validation, Rust generation, and resolver synchronization for Cargo builds.
2//!
3//! Add this crate to your application's build dependencies and call [`build`]
4//! from `build.rs`. No installed CLI or separate generation command is required.
5//!
6//! # Build script
7//!
8//! ```no_run
9//! fn main() -> Result<(), necrassrs_build::BuildError> {
10//!     necrassrs_build::build("schema")
11//! }
12//! ```
13//!
14//! Include the disposable output in your application's `src/generated.rs`:
15//!
16//! ```text
17//! include!(concat!(env!("OUT_DIR"), "/necrassrs.rs"));
18//! ```
19//!
20//! Declare `mod generated;` and `mod resolvers;` in the application root.
21//! Generated code refers to the application's `necrassrs` runtime dependency.
22//! Contracts live in `generated::resolvers`, argument structs in
23//! `generated::types::<Object>::<field>::Args`, and execution wiring in
24//! `generated::dispatch`. The generated `SDL` constant contains the schema.
25//!
26//! # Source ownership
27//!
28//! **Building modifies `src/resolvers.rs`.** SDL owns resolver declarations;
29//! applications own retained resolver bodies and unrelated code. New fields get
30//! explicit `unimplemented!()` stubs. Deleted fields lose their resolver
31//! declarations and bodies. A rename is a deletion plus a new stub, without body
32//! migration. Review source changes after editing SDL. Retained bodies may need
33//! manual changes when arguments or result types change.
34//!
35//! Generated contract types cover built-in scalars, enums, ordinary input
36//! objects, lists, nullable wrappers, owned Object results, and Interface and
37//! Union results. Query and mutation dispatch accept built-in scalar, enum, and
38//! ordinary input object arguments and support nullable/list forms, OneOf inputs,
39//! recursive input boxing, and recursive Object relationships. Custom scalars,
40//! borrowed Object results, and subscription roots are unsupported.
41//! Applications must configure `panic = "abort"` in their root dev
42//! and release profiles to enforce process termination on unimplemented calls.
43//! This affects all panics; ordinary application failures should be returned as
44//! runtime resolver errors.
45
46use reed_compiler::Schema;
47use std::path::{Path, PathBuf};
48
49pub mod codegen;
50mod sync;
51
52/// Generates `OUT_DIR/necrassrs.rs` from `.graphql` files recursively discovered
53/// under `schema_dir`, sorted by path. Symbolic-link entries are skipped.
54/// Creates and synchronizes editable query, mutation, and Object resolvers in
55/// `src/resolvers.rs`.
56///
57/// Relative schema paths are resolved against the process's working directory
58/// (the package root for a Cargo build script). Cargo supplies `OUT_DIR` and
59/// `CARGO_MANIFEST_DIR`; the resolver file is under the latter. Emits Cargo
60/// rebuild directives for the schema directory and discovered files.
61///
62/// Retained fields keep their resolver bodies; deleted and renamed fields lose
63/// their old resolver declarations. Source is written only when synchronization
64/// changes it.
65///
66/// # Errors
67///
68/// Returns [`BuildError`] for missing Cargo environment variables, filesystem
69/// failures, invalid SDL, unsupported generation features, or invalid resolver
70/// source. Invalid SDL and generation errors leave resolver source untouched.
71/// Resolver synchronization rejects symbolic-link destinations and unparseable
72/// source. Generated output is written before resolver synchronization, so an
73/// error does not imply that no files changed. Writes are not atomic, and an
74/// interrupted write may leave partial output.
75///
76/// See the [crate documentation](crate) for build-script setup.
77pub 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    // Keep generated output deterministic for the same inputs.
99    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            // Inspect the entry itself so symbolic links are not followed.
143            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/// A failure while reading inputs, generating contracts, or synchronizing source.
163#[derive(Debug)]
164pub enum BuildError {
165    /// A required Cargo environment variable is missing or is not Unicode.
166    Environment {
167        /// Name of the required variable.
168        variable: &'static str,
169        /// Environment lookup failure.
170        source: std::env::VarError,
171    },
172    /// A filesystem operation failed, including a rejected source destination.
173    Io {
174        /// Path associated with the failure.
175        path: std::path::PathBuf,
176        /// Underlying I/O error.
177        source: std::io::Error,
178    },
179    /// SDL parsing or schema validation diagnostics from Reed Compiler.
180    Schema(reed_compiler::validation::DiagnosticList),
181    /// A valid GraphQL schema uses an unsupported generation feature.
182    Codegen(codegen::CodegenError),
183    /// Existing or synchronized resolver source could not be parsed or reconciled.
184    ResolverSource {
185        /// Resolver implementation file.
186        path: PathBuf,
187        /// Source parsing or reconciliation error.
188        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}