1use anyhow::{Context, Result, bail};
2use bpaf::Bpaf;
3
4#[derive(Debug, Clone, Bpaf)]
5#[bpaf(generate(schema_command_inner))]
6pub enum SchemaCommand {
7 #[bpaf(command("migrate"))]
8 Migrate(#[bpaf(external(migrate_args))] MigrateArgs),
10}
11
12pub fn schema_command() -> impl bpaf::Parser<SchemaCommand> {
14 schema_command_inner()
15}
16
17#[derive(Debug, Clone, Bpaf)]
18pub struct MigrateArgs {
19 #[bpaf(positional("URL"))]
21 pub url: String,
22}
23
24pub async fn run(cmd: SchemaCommand) -> Result<()> {
28 match cmd {
29 SchemaCommand::Migrate(args) => run_migrate(args).await,
30 }
31}
32
33async fn run_migrate(args: MigrateArgs) -> Result<()> {
34 let url = url::Url::parse(&args.url).with_context(|| format!("invalid URL: {}", args.url))?;
35 let text = fetch_schema(&url).await?;
36 let mut value: serde_json::Value =
37 serde_json::from_str(&text).context("failed to parse schema as JSON")?;
38
39 jsonschema_migrate::migrate_to_2020_12(&mut value);
40
41 match serde_json::from_value::<jsonschema_migrate::Schema>(value.clone()) {
42 Ok(schema) => {
43 let output =
44 serde_json::to_string_pretty(&schema).context("failed to serialize schema")?;
45 println!("{output}");
46 Ok(())
47 }
48 Err(e) => {
49 eprintln!("Error: deserialization failed: {e}");
50 eprintln!();
51 diagnose_schema_value_errors(&value, "");
52 std::process::exit(1);
53 }
54 }
55}
56
57fn diagnose_schema_value_errors(value: &serde_json::Value, path: &str) {
59 let serde_json::Value::Object(obj) = value else {
60 return;
61 };
62
63 if let Err(e) = serde_json::from_value::<jsonschema_migrate::Schema>(value.clone()) {
65 let err_str = e.to_string();
66 if !err_str.contains("did not match any variant") {
68 eprintln!(" {path}: {err_str}");
69 return;
70 }
71 } else {
72 return;
74 }
75
76 for key in [
78 "if",
79 "then",
80 "else",
81 "not",
82 "additionalProperties",
83 "items",
84 "contains",
85 "propertyNames",
86 "unevaluatedItems",
87 "unevaluatedProperties",
88 "contentSchema",
89 ] {
90 if let Some(v) = obj.get(key) {
91 check_schema_value(v, &format!("{path}/{key}"));
92 }
93 }
94
95 for key in [
97 "properties",
98 "patternProperties",
99 "$defs",
100 "definitions",
101 "dependentSchemas",
102 ] {
103 if let Some(serde_json::Value::Object(map)) = obj.get(key) {
104 for (k, v) in map {
105 check_schema_value(v, &format!("{path}/{key}/{k}"));
106 }
107 }
108 }
109
110 for key in ["allOf", "anyOf", "oneOf", "prefixItems"] {
112 if let Some(serde_json::Value::Array(arr)) = obj.get(key) {
113 for (i, v) in arr.iter().enumerate() {
114 check_schema_value(v, &format!("{path}/{key}/{i}"));
115 }
116 }
117 }
118
119 for (key, expected) in [
121 ("required", "array of strings"),
122 ("enum", "array"),
123 ("examples", "array"),
124 ("type", "string or array"),
125 ] {
126 if let Some(v) = obj.get(key) {
127 let bad = match key {
128 "required" | "enum" | "examples" => !matches!(v, serde_json::Value::Array(_)),
129 "type" => !matches!(
130 v,
131 serde_json::Value::String(_) | serde_json::Value::Array(_)
132 ),
133 _ => false,
134 };
135 if bad {
136 eprintln!(
137 " {path}/{key}: expected {expected}, got {}",
138 value_type_name(v)
139 );
140 }
141 }
142 }
143}
144
145fn check_schema_value(value: &serde_json::Value, path: &str) {
146 match value {
147 serde_json::Value::Bool(_) => {} serde_json::Value::Object(_) => diagnose_schema_value_errors(value, path),
149 other => {
150 eprintln!(
151 " {path}: expected bool or object, got {}: {}",
152 value_type_name(other),
153 truncate_json(other)
154 );
155 }
156 }
157}
158
159fn value_type_name(v: &serde_json::Value) -> &'static str {
160 match v {
161 serde_json::Value::Null => "null",
162 serde_json::Value::Bool(_) => "bool",
163 serde_json::Value::Number(_) => "number",
164 serde_json::Value::String(_) => "string",
165 serde_json::Value::Array(_) => "array",
166 serde_json::Value::Object(_) => "object",
167 }
168}
169
170fn truncate_json(value: &serde_json::Value) -> String {
171 let s = value.to_string();
172 if s.len() > 120 {
173 format!("{}...", &s[..120])
174 } else {
175 s
176 }
177}
178
179async fn fetch_schema(url: &url::Url) -> Result<String> {
180 match url.scheme() {
181 "file" => {
182 let path = url
183 .to_file_path()
184 .map_err(|()| anyhow::anyhow!("invalid file URL: {url}"))?;
185 tokio::fs::read_to_string(&path)
186 .await
187 .with_context(|| format!("failed to read {}", path.display()))
188 }
189 "http" | "https" => {
190 let resp = reqwest::get(url.as_str())
191 .await
192 .with_context(|| format!("failed to fetch {url}"))?;
193 if !resp.status().is_success() {
194 bail!("HTTP {} for {url}", resp.status());
195 }
196 resp.text()
197 .await
198 .with_context(|| format!("failed to read response body from {url}"))
199 }
200 scheme => bail!("unsupported URL scheme: {scheme}"),
201 }
202}