Skip to main content

cargo_typify/
lib.rs

1// Copyright 2025 Oxide Computer Company
2
3//! cargo command to generate Rust code from a JSON Schema.
4
5#![deny(missing_docs)]
6
7use std::path::PathBuf;
8
9use clap::{ArgGroup, Args};
10use color_eyre::eyre::{Context, Result};
11use typify::{CrateVers, TypeSpace, TypeSpaceSettings, UnknownPolicy};
12
13/// A CLI for the `typify` crate that converts JSON Schema files to Rust code.
14#[derive(Args)]
15#[command(author, version, about)]
16#[command(group(
17    ArgGroup::new("build")
18        .args(["builder", "no_builder"]),
19))]
20pub struct CliArgs {
21    /// The input file to read from
22    pub input: PathBuf,
23
24    /// Whether to include a builder-style interface, this is the default.
25    #[arg(short, long, default_value = "false", group = "build")]
26    pub builder: bool,
27
28    /// Inverse of `--builder`. When set the builder-style interface will not
29    /// be included.
30    #[arg(short = 'B', long, default_value = "false", group = "build")]
31    pub no_builder: bool,
32
33    /// Add an additional derive macro to apply to all defined types.
34    #[arg(short = 'd', long = "additional-derive", value_name = "derive")]
35    pub additional_derives: Vec<String>,
36
37    /// Add an additional attribute to apply to all defined types.
38    #[arg(short = 'a', long = "additional-attr", value_name = "attr")]
39    pub additional_attrs: Vec<String>,
40
41    /// The output file to write to. If not specified, the input file name will
42    /// be used with a `.rs` extension.
43    ///
44    /// If `-` is specified, the output will be written to stdout.
45    #[arg(short, long)]
46    pub output: Option<PathBuf>,
47
48    /// Specify each crate@version that can be assumed to be in use for types
49    /// found in the schema with the x-rust-type extension.
50    #[arg(long = "crate")]
51    crates: Vec<CrateSpec>,
52
53    /// Specify the map like type to use.
54    #[arg(long = "map-type")]
55    map_type: Option<String>,
56
57    /// Specify the policy unknown crates found in schemas with the
58    /// x-rust-type extension.
59    #[arg(
60        long = "unknown-crates",
61        value_parser = ["generate", "allow", "deny"]
62    )]
63    unknown_crates: Option<String>,
64}
65
66impl CliArgs {
67    /// Output path.
68    pub fn output_path(&self) -> Option<PathBuf> {
69        match &self.output {
70            Some(output_path) => {
71                if output_path == &PathBuf::from("-") {
72                    None
73                } else {
74                    Some(output_path.clone())
75                }
76            }
77            None => {
78                let mut output = self.input.clone();
79                output.set_extension("rs");
80                Some(output)
81            }
82        }
83    }
84
85    /// Whether builder-style interface was selected.
86    pub fn use_builder(&self) -> bool {
87        !self.no_builder
88    }
89}
90
91#[derive(Debug, Clone)]
92struct CrateSpec {
93    name: String,
94    version: CrateVers,
95    rename: Option<String>,
96}
97
98impl std::str::FromStr for CrateSpec {
99    type Err = &'static str;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        fn is_crate(s: &str) -> bool {
103            !s.contains(|cc: char| !cc.is_alphabetic() && cc != '-' && cc != '_')
104        }
105
106        fn convert(s: &str) -> Option<CrateSpec> {
107            let (rename, s) = if let Some(ii) = s.find('=') {
108                let rename = &s[..ii];
109                let rest = &s[ii + 1..];
110                if !is_crate(rename) {
111                    return None;
112                }
113                (Some(rename.to_string()), rest)
114            } else {
115                (None, s)
116            };
117
118            let ii = s.find('@')?;
119            let crate_str = &s[..ii];
120            let vers_str = &s[ii + 1..];
121
122            if !is_crate(crate_str) {
123                return None;
124            }
125            let version = CrateVers::parse(vers_str)?;
126
127            Some(CrateSpec {
128                name: crate_str.to_string(),
129                version,
130                rename,
131            })
132        }
133
134        convert(s).ok_or("crate specifier must be of the form 'cratename@version'")
135    }
136}
137
138/// Generate Rust code for the selected JSON Schema.
139pub fn convert(args: &CliArgs) -> Result<String> {
140    let content = std::fs::read_to_string(&args.input)
141        .wrap_err_with(|| format!("Failed to open input file: {}", &args.input.display()))?;
142
143    let schema = serde_json::from_str::<schemars::schema::RootSchema>(&content)
144        .wrap_err("Failed to parse input file as JSON Schema")?;
145
146    let mut settings = TypeSpaceSettings::default();
147    settings.with_struct_builder(args.use_builder());
148
149    for derive in &args.additional_derives {
150        settings.with_derive(derive.clone());
151    }
152
153    for attr in &args.additional_attrs {
154        settings.with_attr(attr.clone());
155    }
156
157    for CrateSpec {
158        name,
159        version,
160        rename,
161    } in &args.crates
162    {
163        settings.with_crate(name, version.clone(), rename.as_ref());
164    }
165
166    if let Some(map_type) = &args.map_type {
167        settings.with_map_type(map_type.as_str());
168    }
169
170    if let Some(unknown_crates) = &args.unknown_crates {
171        let unknown_crates = match unknown_crates.as_str() {
172            "generate" => UnknownPolicy::Generate,
173            "allow" => UnknownPolicy::Allow,
174            "deny" => UnknownPolicy::Deny,
175            _ => unreachable!(),
176        };
177        settings.with_unknown_crates(unknown_crates);
178    }
179
180    let mut type_space = TypeSpace::new(&settings);
181    type_space
182        .add_root_schema(schema)
183        .wrap_err("Schema conversion failed")?;
184
185    let intro = "#![allow(clippy::redundant_closure_call)]
186#![allow(clippy::needless_lifetimes)]
187#![allow(clippy::match_single_binding)]
188#![allow(clippy::clone_on_copy)]
189";
190
191    let contents = format!("{intro}\n{}", type_space.to_stream());
192
193    let contents = rustfmt_wrapper::rustfmt(contents).wrap_err("Failed to format Rust code")?;
194
195    Ok(contents)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_output_parsing_stdout() {
204        let args = CliArgs {
205            input: PathBuf::from("input.json"),
206            builder: false,
207            additional_derives: vec![],
208            additional_attrs: vec![],
209            output: Some(PathBuf::from("-")),
210            no_builder: false,
211            crates: vec![],
212            map_type: None,
213            unknown_crates: Default::default(),
214        };
215
216        assert_eq!(args.output_path(), None);
217    }
218
219    #[test]
220    fn test_output_parsing_file() {
221        let args = CliArgs {
222            input: PathBuf::from("input.json"),
223            builder: false,
224            additional_derives: vec![],
225            additional_attrs: vec![],
226            output: Some(PathBuf::from("some_file.rs")),
227            no_builder: false,
228            crates: vec![],
229            map_type: None,
230            unknown_crates: Default::default(),
231        };
232
233        assert_eq!(args.output_path(), Some(PathBuf::from("some_file.rs")));
234    }
235
236    #[test]
237    fn test_output_parsing_default() {
238        let args = CliArgs {
239            input: PathBuf::from("input.json"),
240            builder: false,
241            additional_derives: vec![],
242            additional_attrs: vec![],
243            output: None,
244            no_builder: false,
245            crates: vec![],
246            map_type: None,
247            unknown_crates: Default::default(),
248        };
249
250        assert_eq!(args.output_path(), Some(PathBuf::from("input.rs")));
251    }
252
253    #[test]
254    fn test_use_btree_map() {
255        let args = CliArgs {
256            input: PathBuf::from("input.json"),
257            builder: false,
258            additional_derives: vec![],
259            additional_attrs: vec![],
260            output: None,
261            no_builder: false,
262            crates: vec![],
263            map_type: Some("::std::collections::BTreeMap".to_string()),
264            unknown_crates: Default::default(),
265        };
266
267        assert_eq!(
268            args.map_type,
269            Some("::std::collections::BTreeMap".to_string())
270        );
271    }
272
273    #[test]
274    fn test_builder_as_default_style() {
275        let args = CliArgs {
276            input: PathBuf::from("input.json"),
277            builder: false,
278            additional_derives: vec![],
279            additional_attrs: vec![],
280            output: None,
281            no_builder: false,
282            crates: vec![],
283            map_type: None,
284            unknown_crates: Default::default(),
285        };
286
287        assert!(args.use_builder());
288    }
289
290    #[test]
291    fn test_no_builder() {
292        let args = CliArgs {
293            input: PathBuf::from("input.json"),
294            builder: false,
295            additional_derives: vec![],
296            additional_attrs: vec![],
297            output: None,
298            no_builder: true,
299            crates: vec![],
300            map_type: None,
301            unknown_crates: Default::default(),
302        };
303
304        assert!(!args.use_builder());
305    }
306
307    #[test]
308    fn test_builder_opt_in() {
309        let args = CliArgs {
310            input: PathBuf::from("input.json"),
311            builder: true,
312            additional_derives: vec![],
313            additional_attrs: vec![],
314            output: None,
315            no_builder: false,
316            crates: vec![],
317            map_type: None,
318            unknown_crates: Default::default(),
319        };
320
321        assert!(args.use_builder());
322    }
323}