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#[derive(Debug, Clone, Bpaf)]
18#[bpaf(generate(explain_args_inner))]
19pub struct ExplainArgs {
20 #[bpaf(long("schema"), argument("URL|FILE"))]
22 pub schema: Option<String>,
23
24 #[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 #[bpaf(long("no-syntax-highlighting"), switch)]
33 pub no_syntax_highlighting: bool,
34
35 #[bpaf(long("no-pager"), switch)]
37 pub no_pager: bool,
38
39 #[bpaf(positional("PATH"))]
41 pub path: Option<String>,
42}
43
44pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
46 explain_args_inner()
47}
48
49#[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 let schema_value = fetch_schema(&schema_uri, is_remote, &args.cache).await?;
75
76 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 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
167fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
170 let mut result = String::new();
171 let mut segments = schema_pointer.split('/').peekable();
172 segments.next();
174 while let Some(seg) = segments.next() {
175 if seg == "properties" {
176 if let Some(prop) = segments.next() {
178 result.push('/');
179 result.push_str(prop);
180 }
181 } else if seg == "items" {
182 } else {
184 result.push('/');
185 result.push_str(seg);
186 }
187 }
188 result
189}
190
191async 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 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}