Skip to main content

lintel_explain/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod path;
4
5use std::io::IsTerminal;
6use std::path::Path;
7
8use anyhow::{Context, Result};
9use bpaf::Bpaf;
10
11use lintel_cli_common::{CLIGlobalOptions, CliCacheOptions};
12
13// ---------------------------------------------------------------------------
14// CLI args
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, Bpaf)]
18#[bpaf(generate(explain_args_inner))]
19pub struct ExplainArgs {
20    /// Schema URL or local file path to explain
21    #[bpaf(long("schema"), argument("URL|FILE"))]
22    pub schema: Option<String>,
23
24    /// Resolve the schema from a data file (like `lintel identify`)
25    #[bpaf(long("file"), argument("FILE"))]
26    pub file: Option<String>,
27
28    #[bpaf(external(lintel_cli_common::cli_cache_options))]
29    pub cache: CliCacheOptions,
30
31    /// Disable syntax highlighting in code blocks
32    #[bpaf(long("no-syntax-highlighting"), switch)]
33    pub no_syntax_highlighting: bool,
34
35    /// Print output directly instead of piping through a pager
36    #[bpaf(long("no-pager"), switch)]
37    pub no_pager: bool,
38
39    /// JSON Pointer (`/properties/name`) or `JSONPath` (`$.name`) to a sub-schema
40    #[bpaf(positional("PATH"))]
41    pub path: Option<String>,
42}
43
44/// Construct the bpaf parser for `ExplainArgs`.
45pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
46    explain_args_inner()
47}
48
49// ---------------------------------------------------------------------------
50// Entry point
51// ---------------------------------------------------------------------------
52
53/// Run the explain command.
54///
55/// # Errors
56///
57/// Returns an error if the schema cannot be fetched, parsed, or the pointer
58/// cannot be resolved.
59#[allow(clippy::missing_panics_doc)]
60pub async fn run(args: ExplainArgs, global: &CLIGlobalOptions) -> Result<bool> {
61    if args.schema.is_none() && args.file.is_none() {
62        anyhow::bail!("either --schema <URL|FILE> or --file <FILE> is required");
63    }
64
65    let (schema_uri, display_name, is_remote) = if let Some(ref file_path) = args.file {
66        resolve_from_file(file_path, &args.cache).await?
67    } else {
68        let uri = args.schema.as_deref().expect("checked above");
69        let is_remote = uri.starts_with("http://") || uri.starts_with("https://");
70        (uri.to_string(), uri.to_string(), is_remote)
71    };
72
73    // Fetch the schema
74    let schema_value = fetch_schema(&schema_uri, is_remote, &args.cache).await?;
75
76    // Resolve path to a schema pointer
77    let pointer = args
78        .path
79        .as_deref()
80        .map(path::to_schema_pointer)
81        .transpose()
82        .map_err(|e| anyhow::anyhow!("{e}"))?;
83
84    // Run validation when explaining a data file, and collect relevant errors.
85    let validation_errors = if args.file.is_some() {
86        let file_path = args.file.as_deref().expect("checked above");
87        let instance_prefix = pointer
88            .as_deref()
89            .map(schema_pointer_to_instance_prefix)
90            .unwrap_or_default();
91        collect_validation_errors(file_path, &args.cache, &instance_prefix).await
92    } else {
93        vec![]
94    };
95
96    let is_tty = std::io::stdout().is_terminal();
97    let use_color = match global.colors {
98        Some(lintel_cli_common::ColorsArg::Force) => true,
99        Some(lintel_cli_common::ColorsArg::Off) => false,
100        None => is_tty,
101    };
102    let opts = jsonschema_explain::ExplainOptions {
103        color: use_color,
104        syntax_highlight: use_color && !args.no_syntax_highlighting,
105        width: terminal_size::terminal_size()
106            .map(|(w, _)| w.0 as usize)
107            .or_else(|| std::env::var("COLUMNS").ok()?.parse().ok())
108            .unwrap_or(80),
109        validation_errors,
110    };
111
112    let output = match pointer.as_deref() {
113        Some(ptr) => jsonschema_explain::explain_at_path(&schema_value, ptr, &display_name, &opts)
114            .map_err(|e| anyhow::anyhow!("{e}"))?,
115        None => jsonschema_explain::explain(&schema_value, &display_name, &opts),
116    };
117
118    if is_tty && !args.no_pager {
119        lintel_cli_common::pipe_to_pager(&output);
120    } else {
121        print!("{output}");
122    }
123
124    Ok(false)
125}
126
127async fn resolve_from_file(
128    file_path: &str,
129    cache: &CliCacheOptions,
130) -> Result<(String, String, bool)> {
131    let path = Path::new(file_path);
132    if !path.exists() {
133        anyhow::bail!("file not found: {file_path}");
134    }
135
136    let resolved = lintel_identify::resolve_schema_for_file(path, cache)
137        .await?
138        .ok_or_else(|| anyhow::anyhow!("no schema found for {file_path}"))?;
139
140    Ok((
141        resolved.schema_uri,
142        resolved.display_name,
143        resolved.is_remote,
144    ))
145}
146
147async fn fetch_schema(
148    schema_uri: &str,
149    is_remote: bool,
150    cache: &CliCacheOptions,
151) -> Result<serde_json::Value> {
152    if is_remote {
153        let retriever = lintel_identify::build_retriever(cache);
154        let (val, _) = retriever
155            .fetch(schema_uri)
156            .await
157            .map_err(|e| anyhow::anyhow!("failed to fetch schema '{schema_uri}': {e}"))?;
158        Ok(val)
159    } else {
160        let content = std::fs::read_to_string(schema_uri)
161            .with_context(|| format!("failed to read schema: {schema_uri}"))?;
162        serde_json::from_str(&content)
163            .with_context(|| format!("failed to parse schema: {schema_uri}"))
164    }
165}
166
167/// Convert a schema pointer (e.g. `/properties/badges`) to an instance path
168/// prefix (e.g. `/badges`) by stripping `/properties/` segments.
169fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
170    let mut result = String::new();
171    let mut segments = schema_pointer.split('/').peekable();
172    // Skip the leading empty segment from the leading `/`.
173    segments.next();
174    while let Some(seg) = segments.next() {
175        if seg == "properties" {
176            // The next segment is the actual property name.
177            if let Some(prop) = segments.next() {
178                result.push('/');
179                result.push_str(prop);
180            }
181        } else if seg == "items" {
182            // Array items — keep descending but don't add to the prefix.
183        } else {
184            result.push('/');
185            result.push_str(seg);
186        }
187    }
188    result
189}
190
191/// Run validation on a data file and return errors filtered to a given
192/// instance path prefix.
193async fn collect_validation_errors(
194    file_path: &str,
195    cache: &CliCacheOptions,
196    instance_prefix: &str,
197) -> Vec<jsonschema_explain::ExplainError> {
198    let validate_args = lintel_check::validate::ValidateArgs {
199        globs: vec![file_path.to_string()],
200        exclude: vec![],
201        cache_dir: cache.cache_dir.clone(),
202        force_schema_fetch: cache.force_schema_fetch || cache.force,
203        force_validation: false,
204        no_catalog: cache.no_catalog,
205        config_dir: None,
206        schema_cache_ttl: cache.schema_cache_ttl,
207    };
208
209    let result = match lintel_check::validate::run(&validate_args).await {
210        Ok(r) => r,
211        Err(e) => {
212            tracing::debug!("validation failed: {e}");
213            return vec![];
214        }
215    };
216
217    result
218        .errors
219        .into_iter()
220        .filter_map(|err| {
221            if let lintel_check::validate::LintError::Validation {
222                instance_path,
223                message,
224                ..
225            } = err
226            {
227                // When explaining the root, show all errors.
228                // Otherwise only show errors under the given property.
229                if instance_prefix.is_empty()
230                    || instance_path == instance_prefix
231                    || instance_path.starts_with(&format!("{instance_prefix}/"))
232                {
233                    Some(jsonschema_explain::ExplainError {
234                        instance_path,
235                        message,
236                    })
237                } else {
238                    None
239                }
240            } else {
241                None
242            }
243        })
244        .collect()
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used)]
249mod tests {
250    use super::*;
251    use bpaf::Parser;
252    use lintel_cli_common::cli_global_options;
253
254    fn test_cli() -> bpaf::OptionParser<(CLIGlobalOptions, ExplainArgs)> {
255        bpaf::construct!(cli_global_options(), explain_args())
256            .to_options()
257            .descr("test explain args")
258    }
259
260    #[test]
261    fn cli_parses_schema_only() -> anyhow::Result<()> {
262        let (_, args) = test_cli()
263            .run_inner(&["--schema", "https://example.com/schema.json"])
264            .map_err(|e| anyhow::anyhow!("{e:?}"))?;
265        assert_eq!(
266            args.schema.as_deref(),
267            Some("https://example.com/schema.json")
268        );
269        assert!(args.file.is_none());
270        assert!(args.path.is_none());
271        Ok(())
272    }
273
274    #[test]
275    fn cli_parses_file_with_pointer() -> anyhow::Result<()> {
276        let (_, args) = test_cli()
277            .run_inner(&["--file", "config.yaml", "/properties/name"])
278            .map_err(|e| anyhow::anyhow!("{e:?}"))?;
279        assert_eq!(args.file.as_deref(), Some("config.yaml"));
280        assert_eq!(args.path.as_deref(), Some("/properties/name"));
281        Ok(())
282    }
283
284    #[test]
285    fn cli_parses_schema_with_jsonpath() -> anyhow::Result<()> {
286        let (_, args) = test_cli()
287            .run_inner(&["--schema", "schema.json", "$.name"])
288            .map_err(|e| anyhow::anyhow!("{e:?}"))?;
289        assert_eq!(args.schema.as_deref(), Some("schema.json"));
290        assert_eq!(args.path.as_deref(), Some("$.name"));
291        Ok(())
292    }
293
294    #[test]
295    fn cli_parses_display_options() -> anyhow::Result<()> {
296        let (_, args) = test_cli()
297            .run_inner(&[
298                "--schema",
299                "s.json",
300                "--no-syntax-highlighting",
301                "--no-pager",
302            ])
303            .map_err(|e| anyhow::anyhow!("{e:?}"))?;
304        assert!(args.no_syntax_highlighting);
305        assert!(args.no_pager);
306        Ok(())
307    }
308
309    #[test]
310    fn cli_parses_cache_options() -> anyhow::Result<()> {
311        let (_, args) = test_cli()
312            .run_inner(&[
313                "--schema",
314                "s.json",
315                "--cache-dir",
316                "/tmp/cache",
317                "--no-catalog",
318            ])
319            .map_err(|e| anyhow::anyhow!("{e:?}"))?;
320        assert_eq!(args.cache.cache_dir.as_deref(), Some("/tmp/cache"));
321        assert!(args.cache.no_catalog);
322        Ok(())
323    }
324}