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