Skip to main content

lintel_schema/
lib.rs

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 a JSON Schema to draft 2020-12
9    Migrate(#[bpaf(external(migrate_args))] MigrateArgs),
10}
11
12/// Construct the bpaf parser for [`SchemaCommand`].
13pub fn schema_command() -> impl bpaf::Parser<SchemaCommand> {
14    schema_command_inner()
15}
16
17#[derive(Debug, Clone, Bpaf)]
18pub struct MigrateArgs {
19    /// Schema URL (http://, https://, or file://)
20    #[bpaf(positional("URL"))]
21    pub url: String,
22}
23
24/// # Errors
25///
26/// Returns an error if the schema cannot be fetched, parsed, or migrated.
27pub 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
57/// Recursively try to deserialize each schema position and report failures.
58fn diagnose_schema_value_errors(value: &serde_json::Value, path: &str) {
59    let serde_json::Value::Object(obj) = value else {
60        return;
61    };
62
63    // Try the object itself as a Schema — if it fails, report why
64    if let Err(e) = serde_json::from_value::<jsonschema_migrate::Schema>(value.clone()) {
65        let err_str = e.to_string();
66        // Only report if this is a leaf error (not just "didn't match untagged enum")
67        if !err_str.contains("did not match any variant") {
68            eprintln!("  {path}: {err_str}");
69            return;
70        }
71    } else {
72        // This object deserializes fine — no need to recurse
73        return;
74    }
75
76    // Check single-schema positions
77    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    // Check map-schema positions
96    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    // Check array-schema positions
111    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    // Also check non-schema fields for type mismatches
120    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(_) => {} // Always valid as SchemaValue::Bool
148        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}