1#![doc = include_str!("../README.md")]
2
3mod path;
4
5use std::io::IsTerminal;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use bpaf::{Bpaf, ShellComp};
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"), complete_shell(ShellComp::File { mask: None }))]
24 pub schema: Option<String>,
25
26 #[bpaf(long("file"), argument("FILE|URL"), complete_shell(ShellComp::File { mask: None }))]
30 pub file: Option<String>,
31
32 #[bpaf(long("path"), argument("FILE|URL"), complete_shell(ShellComp::File { mask: None }))]
36 pub resolve_path: Option<String>,
37
38 #[bpaf(external(lintel_cli_common::cli_cache_options))]
39 pub cache: CliCacheOptions,
40
41 #[bpaf(long("no-syntax-highlighting"), switch)]
43 pub no_syntax_highlighting: bool,
44
45 #[bpaf(long("no-pager"), switch)]
47 pub no_pager: bool,
48
49 #[bpaf(positional("FILE|POINTER"), complete_shell(ShellComp::File { mask: None }))]
57 pub positional: Option<String>,
58
59 #[bpaf(positional("POINTER"))]
64 pub pointer: Option<String>,
65}
66
67pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
69 explain_args_inner()
70}
71
72fn is_url(s: &str) -> bool {
77 s.starts_with("http://") || s.starts_with("https://")
78}
79
80fn url_filename(url: &str) -> String {
82 url.rsplit('/')
83 .next()
84 .and_then(|seg| {
85 let seg = seg.split('?').next().unwrap_or(seg);
87 let seg = seg.split('#').next().unwrap_or(seg);
88 if seg.is_empty() {
89 None
90 } else {
91 Some(seg.to_string())
92 }
93 })
94 .unwrap_or_else(|| "file".to_string())
95}
96
97async fn fetch_url_content(url: &str) -> Result<String> {
99 let resp = reqwest::get(url)
100 .await
101 .with_context(|| format!("failed to fetch URL: {url}"))?;
102 let status = resp.status();
103 if !status.is_success() {
104 anyhow::bail!("HTTP {status} fetching {url}");
105 }
106 resp.text()
107 .await
108 .with_context(|| format!("failed to read response body from {url}"))
109}
110
111struct FetchedData {
113 content: String,
114 filename: String,
115}
116
117struct TempDataFile {
120 _dir: tempfile::TempDir,
121 file_path: PathBuf,
122}
123
124impl TempDataFile {
125 fn new(filename: &str, content: &str) -> Result<Self> {
126 let dir = tempfile::tempdir().context("failed to create temp directory")?;
127 let file_path = dir.path().join(filename);
128 std::fs::write(&file_path, content)
129 .with_context(|| format!("failed to write temp file: {}", file_path.display()))?;
130 Ok(Self {
131 _dir: dir,
132 file_path,
133 })
134 }
135}
136
137#[allow(clippy::missing_panics_doc)]
148pub async fn run(args: ExplainArgs, global: &CLIGlobalOptions) -> Result<bool> {
149 let has_flag = args.file.is_some() || args.resolve_path.is_some() || args.schema.is_some();
152 let mut args = args;
153 let pointer_str = if has_flag {
154 if args.pointer.is_some() {
156 anyhow::bail!("unexpected extra positional argument");
157 }
158 args.positional.take()
159 } else if args.positional.is_some() {
160 args.resolve_path = args.positional.take();
162 args.pointer.take()
163 } else {
164 anyhow::bail!(
165 "a file path or one of --file <FILE>, --path <FILE>, --schema <URL|FILE> is required"
166 );
167 };
168
169 let data_source_str = args.file.as_deref().or(args.resolve_path.as_deref());
170 let is_file_flag = args.file.is_some();
171
172 let fetched = fetch_data_source(data_source_str).await?;
173
174 let (schema_uri, display_name, is_remote) =
175 resolve_schema_info(&args, data_source_str, is_file_flag, fetched.as_ref()).await?;
176
177 let schema_value = fetch_schema(&schema_uri, is_remote, &args.cache).await?;
178
179 let pointer = pointer_str
180 .as_deref()
181 .map(path::to_schema_pointer)
182 .transpose()
183 .map_err(|e| anyhow::anyhow!("{e}"))?;
184
185 let instance_prefix = pointer
186 .as_deref()
187 .map(schema_pointer_to_instance_prefix)
188 .unwrap_or_default();
189
190 let validation_errors = run_validation(
191 fetched.as_ref(),
192 data_source_str,
193 &args.cache,
194 &instance_prefix,
195 )
196 .await?;
197
198 render_output(
199 global,
200 &args,
201 &schema_value,
202 &display_name,
203 pointer.as_deref(),
204 validation_errors,
205 )
206}
207
208async fn fetch_data_source(data_source_str: Option<&str>) -> Result<Option<FetchedData>> {
210 let Some(src) = data_source_str else {
211 return Ok(None);
212 };
213 if !is_url(src) {
214 return Ok(None);
215 }
216 let content = fetch_url_content(src).await?;
217 let filename = url_filename(src);
218 Ok(Some(FetchedData { content, filename }))
219}
220
221async fn resolve_schema_info(
223 args: &ExplainArgs,
224 data_source_str: Option<&str>,
225 is_file_flag: bool,
226 fetched: Option<&FetchedData>,
227) -> Result<(String, String, bool)> {
228 if let Some(ref schema) = args.schema {
229 let is_remote = is_url(schema);
230 if !is_remote && !is_url(data_source_str.unwrap_or("")) {
231 let resolved = data_source_str
232 .map(Path::new)
233 .and_then(|p| p.parent())
234 .map_or_else(
235 || schema.clone(),
236 |parent| parent.join(schema).to_string_lossy().to_string(),
237 );
238 Ok((resolved.clone(), resolved, false))
239 } else {
240 Ok((schema.clone(), schema.clone(), is_remote))
241 }
242 } else if let Some(fetched) = fetched {
243 let cwd = std::env::current_dir().ok();
244 let virtual_path = PathBuf::from(&fetched.filename);
245 let resolved = lintel_identify::resolve_schema_for_content(
246 &fetched.content,
247 &virtual_path,
248 cwd.as_deref(),
249 &args.cache,
250 )
251 .await?
252 .ok_or_else(|| {
253 anyhow::anyhow!("no schema found for URL: {}", data_source_str.unwrap_or(""))
254 })?;
255 Ok((
256 resolved.schema_uri,
257 resolved.display_name,
258 resolved.is_remote,
259 ))
260 } else if let Some(src) = data_source_str {
261 resolve_local_schema(src, is_file_flag, &args.cache).await
262 } else {
263 unreachable!("at least --schema is set (checked above)")
264 }
265}
266
267async fn resolve_local_schema(
269 src: &str,
270 is_file_flag: bool,
271 cache: &CliCacheOptions,
272) -> Result<(String, String, bool)> {
273 let path = Path::new(src);
274 if path.exists() {
275 let resolved = lintel_identify::resolve_schema_for_file(path, cache)
276 .await?
277 .ok_or_else(|| anyhow::anyhow!("no schema found for {src}"))?;
278 Ok((
279 resolved.schema_uri,
280 resolved.display_name,
281 resolved.is_remote,
282 ))
283 } else if is_file_flag {
284 anyhow::bail!("file not found: {src}");
285 } else {
286 let resolved = lintel_identify::resolve_schema_for_path(path, cache)
287 .await?
288 .ok_or_else(|| anyhow::anyhow!("no schema found for path: {src}"))?;
289 Ok((
290 resolved.schema_uri,
291 resolved.display_name,
292 resolved.is_remote,
293 ))
294 }
295}
296
297async fn run_validation(
299 fetched: Option<&FetchedData>,
300 data_source_str: Option<&str>,
301 cache: &CliCacheOptions,
302 instance_prefix: &str,
303) -> Result<Vec<jsonschema_explain::ExplainError>> {
304 if let Some(fetched) = fetched {
305 let temp = TempDataFile::new(&fetched.filename, &fetched.content)?;
306 let config_dir = std::env::current_dir().ok();
307 Ok(collect_validation_errors(
308 &temp.file_path.to_string_lossy(),
309 cache,
310 instance_prefix,
311 config_dir,
312 )
313 .await)
314 } else if let Some(src) = data_source_str {
315 if Path::new(src).exists() {
316 Ok(collect_validation_errors(src, cache, instance_prefix, None).await)
317 } else {
318 Ok(vec![])
319 }
320 } else {
321 Ok(vec![])
322 }
323}
324
325#[allow(clippy::too_many_arguments)]
327fn render_output(
328 global: &CLIGlobalOptions,
329 args: &ExplainArgs,
330 schema_value: &serde_json::Value,
331 display_name: &str,
332 pointer: Option<&str>,
333 validation_errors: Vec<jsonschema_explain::ExplainError>,
334) -> Result<bool> {
335 let is_tty = std::io::stdout().is_terminal();
336 let use_color = match global.colors {
337 Some(lintel_cli_common::ColorsArg::Force) => true,
338 Some(lintel_cli_common::ColorsArg::Off) => false,
339 None => is_tty,
340 };
341 let opts = jsonschema_explain::ExplainOptions {
342 color: use_color,
343 syntax_highlight: use_color && !args.no_syntax_highlighting,
344 width: terminal_size::terminal_size()
345 .map(|(w, _)| w.0 as usize)
346 .or_else(|| std::env::var("COLUMNS").ok()?.parse().ok())
347 .unwrap_or(80),
348 validation_errors,
349 };
350
351 let output = match pointer {
352 Some(ptr) => jsonschema_explain::explain_at_path(schema_value, ptr, display_name, &opts)
353 .map_err(|e| anyhow::anyhow!("{e}"))?,
354 None => jsonschema_explain::explain(schema_value, display_name, &opts),
355 };
356
357 if is_tty && !args.no_pager {
358 lintel_cli_common::pipe_to_pager(&output);
359 } else {
360 print!("{output}");
361 }
362
363 Ok(false)
364}
365
366async fn fetch_schema(
367 schema_uri: &str,
368 is_remote: bool,
369 cache: &CliCacheOptions,
370) -> Result<serde_json::Value> {
371 if is_remote {
372 let retriever = lintel_identify::build_retriever(cache);
373 let (val, _) = retriever
374 .fetch(schema_uri)
375 .await
376 .map_err(|e| anyhow::anyhow!("failed to fetch schema '{schema_uri}': {e}"))?;
377 Ok(val)
378 } else {
379 let content = std::fs::read_to_string(schema_uri)
380 .with_context(|| format!("failed to read schema: {schema_uri}"))?;
381 serde_json::from_str(&content)
382 .with_context(|| format!("failed to parse schema: {schema_uri}"))
383 }
384}
385
386fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
389 let mut result = String::new();
390 let mut segments = schema_pointer.split('/').peekable();
391 segments.next();
393 while let Some(seg) = segments.next() {
394 if seg == "properties" {
395 if let Some(prop) = segments.next() {
397 result.push('/');
398 result.push_str(prop);
399 }
400 } else if seg == "items" {
401 } else {
403 result.push('/');
404 result.push_str(seg);
405 }
406 }
407 result
408}
409
410async fn collect_validation_errors(
413 file_path: &str,
414 cache: &CliCacheOptions,
415 instance_prefix: &str,
416 config_dir: Option<PathBuf>,
417) -> Vec<jsonschema_explain::ExplainError> {
418 let validate_args = lintel_validate::validate::ValidateArgs {
419 globs: vec![file_path.to_string()],
420 exclude: vec![],
421 cache_dir: cache.cache_dir.clone(),
422 force_schema_fetch: cache.force_schema_fetch || cache.force,
423 force_validation: false,
424 no_catalog: cache.no_catalog,
425 config_dir,
426 schema_cache_ttl: cache.schema_cache_ttl,
427 };
428
429 let result = match lintel_validate::validate::run(&validate_args).await {
430 Ok(r) => r,
431 Err(e) => {
432 tracing::debug!("validation failed: {e}");
433 return vec![];
434 }
435 };
436
437 result
438 .errors
439 .into_iter()
440 .filter_map(|err| {
441 if let lintel_validate::validate::LintError::Validation {
442 instance_path,
443 message,
444 ..
445 } = err
446 {
447 if instance_prefix.is_empty()
450 || instance_path == instance_prefix
451 || instance_path.starts_with(&format!("{instance_prefix}/"))
452 {
453 Some(jsonschema_explain::ExplainError {
454 instance_path,
455 message,
456 })
457 } else {
458 None
459 }
460 } else {
461 None
462 }
463 })
464 .collect()
465}
466
467#[cfg(test)]
468#[allow(clippy::unwrap_used)]
469mod tests {
470 use super::*;
471 use bpaf::Parser;
472 use lintel_cli_common::cli_global_options;
473
474 fn test_cli() -> bpaf::OptionParser<(CLIGlobalOptions, ExplainArgs)> {
475 bpaf::construct!(cli_global_options(), explain_args())
476 .to_options()
477 .descr("test explain args")
478 }
479
480 #[test]
481 fn cli_parses_schema_only() -> anyhow::Result<()> {
482 let (_, args) = test_cli()
483 .run_inner(&["--schema", "https://example.com/schema.json"])
484 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
485 assert_eq!(
486 args.schema.as_deref(),
487 Some("https://example.com/schema.json")
488 );
489 assert!(args.file.is_none());
490 assert!(args.positional.is_none());
491 Ok(())
492 }
493
494 #[test]
495 fn cli_parses_file_with_pointer() -> anyhow::Result<()> {
496 let (_, args) = test_cli()
497 .run_inner(&["--file", "config.yaml", "/properties/name"])
498 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
499 assert_eq!(args.file.as_deref(), Some("config.yaml"));
500 assert_eq!(args.positional.as_deref(), Some("/properties/name"));
501 Ok(())
502 }
503
504 #[test]
505 fn cli_parses_schema_with_jsonpath() -> anyhow::Result<()> {
506 let (_, args) = test_cli()
507 .run_inner(&["--schema", "schema.json", "$.name"])
508 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
509 assert_eq!(args.schema.as_deref(), Some("schema.json"));
510 assert_eq!(args.positional.as_deref(), Some("$.name"));
511 Ok(())
512 }
513
514 #[test]
515 fn cli_parses_display_options() -> anyhow::Result<()> {
516 let (_, args) = test_cli()
517 .run_inner(&[
518 "--schema",
519 "s.json",
520 "--no-syntax-highlighting",
521 "--no-pager",
522 ])
523 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
524 assert!(args.no_syntax_highlighting);
525 assert!(args.no_pager);
526 Ok(())
527 }
528
529 #[test]
530 fn cli_parses_path_only() -> anyhow::Result<()> {
531 let (_, args) = test_cli()
532 .run_inner(&["--path", "tsconfig.json"])
533 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
534 assert_eq!(args.resolve_path.as_deref(), Some("tsconfig.json"));
535 assert!(args.file.is_none());
536 assert!(args.schema.is_none());
537 assert!(args.positional.is_none());
538 Ok(())
539 }
540
541 #[test]
542 fn cli_parses_path_with_pointer() -> anyhow::Result<()> {
543 let (_, args) = test_cli()
544 .run_inner(&["--path", "config.yaml", "/properties/name"])
545 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
546 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
547 assert_eq!(args.positional.as_deref(), Some("/properties/name"));
548 Ok(())
549 }
550
551 #[test]
552 fn cli_parses_path_with_jsonpath() -> anyhow::Result<()> {
553 let (_, args) = test_cli()
554 .run_inner(&["--path", "config.yaml", "$.name"])
555 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
556 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
557 assert_eq!(args.positional.as_deref(), Some("$.name"));
558 Ok(())
559 }
560
561 #[test]
562 fn cli_file_takes_precedence_over_path() -> anyhow::Result<()> {
563 let (_, args) = test_cli()
564 .run_inner(&["--file", "data.yaml", "--path", "other.yaml"])
565 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
566 assert_eq!(args.file.as_deref(), Some("data.yaml"));
567 assert_eq!(args.resolve_path.as_deref(), Some("other.yaml"));
568 Ok(())
570 }
571
572 #[test]
573 fn cli_path_takes_precedence_over_schema() -> anyhow::Result<()> {
574 let (_, args) = test_cli()
575 .run_inner(&["--path", "config.yaml", "--schema", "s.json"])
576 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
577 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
578 assert_eq!(args.schema.as_deref(), Some("s.json"));
579 Ok(())
581 }
582
583 #[test]
584 fn cli_schema_with_file() -> anyhow::Result<()> {
585 let (_, args) = test_cli()
586 .run_inner(&["--schema", "s.json", "--file", "data.yaml"])
587 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
588 assert_eq!(args.schema.as_deref(), Some("s.json"));
589 assert_eq!(args.file.as_deref(), Some("data.yaml"));
590 Ok(())
591 }
592
593 #[test]
594 fn cli_schema_with_path() -> anyhow::Result<()> {
595 let (_, args) = test_cli()
596 .run_inner(&["--schema", "s.json", "--path", "data.yaml"])
597 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
598 assert_eq!(args.schema.as_deref(), Some("s.json"));
599 assert_eq!(args.resolve_path.as_deref(), Some("data.yaml"));
600 Ok(())
601 }
602
603 #[tokio::test]
604 async fn run_rejects_no_source() {
605 let args = ExplainArgs {
606 schema: None,
607 file: None,
608 resolve_path: None,
609 cache: CliCacheOptions {
610 cache_dir: None,
611 schema_cache_ttl: None,
612 force_schema_fetch: false,
613 force_validation: false,
614 force: false,
615 no_catalog: false,
616 },
617 no_syntax_highlighting: false,
618 no_pager: false,
619 positional: None,
620 pointer: None,
621 };
622 let global = CLIGlobalOptions {
623 colors: None,
624 verbose: false,
625 log_level: lintel_cli_common::LogLevel::None,
626 };
627 let err = run(args, &global).await.unwrap_err();
628 assert!(
629 err.to_string().contains("a file path or one of --file"),
630 "unexpected error: {err}"
631 );
632 }
633
634 #[test]
635 fn cli_parses_cache_options() -> anyhow::Result<()> {
636 let (_, args) = test_cli()
637 .run_inner(&[
638 "--schema",
639 "s.json",
640 "--cache-dir",
641 "/tmp/cache",
642 "--no-catalog",
643 ])
644 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
645 assert_eq!(args.cache.cache_dir.as_deref(), Some("/tmp/cache"));
646 assert!(args.cache.no_catalog);
647 Ok(())
648 }
649
650 #[test]
653 fn cli_positional_file_only() -> anyhow::Result<()> {
654 let (_, args) = test_cli()
655 .run_inner(&["package.json"])
656 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
657 assert_eq!(args.positional.as_deref(), Some("package.json"));
658 assert!(args.pointer.is_none());
659 assert!(args.file.is_none());
660 assert!(args.resolve_path.is_none());
661 assert!(args.schema.is_none());
662 Ok(())
663 }
664
665 #[test]
666 fn cli_positional_file_with_pointer() -> anyhow::Result<()> {
667 let (_, args) = test_cli()
668 .run_inner(&["package.json", "name"])
669 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
670 assert_eq!(args.positional.as_deref(), Some("package.json"));
671 assert_eq!(args.pointer.as_deref(), Some("name"));
672 assert!(args.file.is_none());
673 assert!(args.resolve_path.is_none());
674 assert!(args.schema.is_none());
675 Ok(())
676 }
677
678 #[test]
679 fn cli_positional_file_with_json_pointer() -> anyhow::Result<()> {
680 let (_, args) = test_cli()
681 .run_inner(&["config.yaml", "/properties/name"])
682 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
683 assert_eq!(args.positional.as_deref(), Some("config.yaml"));
684 assert_eq!(args.pointer.as_deref(), Some("/properties/name"));
685 Ok(())
686 }
687
688 #[test]
691 fn url_filename_simple() {
692 assert_eq!(
693 url_filename("https://example.com/package.json"),
694 "package.json"
695 );
696 }
697
698 #[test]
699 fn url_filename_with_query() {
700 assert_eq!(
701 url_filename("https://example.com/config.yaml?ref=main"),
702 "config.yaml"
703 );
704 }
705
706 #[test]
707 fn url_filename_with_fragment() {
708 assert_eq!(
709 url_filename("https://example.com/config.yaml#section"),
710 "config.yaml"
711 );
712 }
713
714 #[test]
715 fn url_filename_nested_path() {
716 assert_eq!(
717 url_filename(
718 "https://raw.githubusercontent.com/org/repo/main/.github/workflows/ci.yml"
719 ),
720 "ci.yml"
721 );
722 }
723
724 #[test]
725 fn url_filename_trailing_slash() {
726 assert_eq!(url_filename("https://example.com/"), "file");
727 }
728
729 #[test]
732 fn is_url_detects_https() {
733 assert!(is_url("https://example.com/schema.json"));
734 }
735
736 #[test]
737 fn is_url_detects_http() {
738 assert!(is_url("http://example.com/schema.json"));
739 }
740
741 #[test]
742 fn is_url_rejects_local() {
743 assert!(!is_url("./schema.json"));
744 assert!(!is_url("/tmp/schema.json"));
745 assert!(!is_url("schema.json"));
746 }
747}