1#![doc = include_str!("../README.md")]
2
3extern crate alloc;
4
5mod inline;
6mod path;
7pub mod resolve;
8
9pub use resolve::{ResolvedFileSchema, SchemaSource, build_retriever};
10
11use std::io::IsTerminal;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use bpaf::{Bpaf, ShellComp};
16
17use lintel_cli_common::{CLIGlobalOptions, CliCacheOptions};
18
19#[derive(Debug, Clone, Bpaf)]
24#[bpaf(generate(explain_args_inner))]
25pub struct ExplainArgs {
26 #[bpaf(long("schema"), argument("URL|FILE"), complete_shell(ShellComp::File { mask: None }))]
30 pub schema: Option<String>,
31
32 #[bpaf(long("file"), argument("FILE|URL"), complete_shell(ShellComp::File { mask: None }))]
36 pub file: Option<String>,
37
38 #[bpaf(long("path"), argument("FILE|URL"), complete_shell(ShellComp::File { mask: None }))]
42 pub resolve_path: Option<String>,
43
44 #[bpaf(external(lintel_cli_common::cli_cache_options))]
45 pub cache: CliCacheOptions,
46
47 #[bpaf(long("no-syntax-highlighting"), switch)]
49 pub no_syntax_highlighting: bool,
50
51 #[bpaf(long("no-pager"), switch)]
53 pub no_pager: bool,
54
55 #[bpaf(long("extended"), switch)]
57 pub extended: bool,
58
59 #[bpaf(positional("FILE|POINTER"), complete_shell(ShellComp::File { mask: None }))]
67 pub positional: Option<String>,
68
69 #[bpaf(positional("POINTER"))]
74 pub pointer: Option<String>,
75}
76
77pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
79 explain_args_inner()
80}
81
82fn is_url(s: &str) -> bool {
87 s.starts_with("http://") || s.starts_with("https://")
88}
89
90fn split_schema_fragment(schema: &str) -> (String, Option<String>) {
97 if is_url(schema)
98 && let Ok(parsed) = url::Url::parse(schema)
99 {
100 let fragment = parsed
101 .fragment()
102 .map(String::from)
103 .filter(|f| !f.is_empty());
104 let mut base = parsed;
105 base.set_fragment(None);
106 return (base.to_string(), fragment);
107 }
108 if let Some(pos) = schema.find('#') {
110 let frag = &schema[pos + 1..];
111 let base = schema[..pos].to_string();
112 if frag.is_empty() {
113 (base, None)
114 } else {
115 (base, Some(frag.to_string()))
116 }
117 } else {
118 (schema.to_string(), None)
119 }
120}
121
122fn url_filename(url: &str) -> String {
124 url.rsplit('/')
125 .next()
126 .and_then(|seg| {
127 let seg = seg.split('?').next().unwrap_or(seg);
129 let seg = seg.split('#').next().unwrap_or(seg);
130 if seg.is_empty() {
131 None
132 } else {
133 Some(seg.to_string())
134 }
135 })
136 .unwrap_or_else(|| "file".to_string())
137}
138
139async fn fetch_url_content(url: &str) -> Result<String> {
141 let resp = reqwest::get(url)
142 .await
143 .with_context(|| format!("failed to fetch URL: {url}"))?;
144 let status = resp.status();
145 if !status.is_success() {
146 anyhow::bail!("HTTP {status} fetching {url}");
147 }
148 resp.text()
149 .await
150 .with_context(|| format!("failed to read response body from {url}"))
151}
152
153struct FetchedData {
155 content: String,
156 filename: String,
157}
158
159struct TempDataFile {
162 _dir: tempfile::TempDir,
163 file_path: PathBuf,
164}
165
166impl TempDataFile {
167 fn new(filename: &str, content: &str) -> Result<Self> {
168 let dir = tempfile::tempdir().context("failed to create temp directory")?;
169 let file_path = dir.path().join(filename);
170 std::fs::write(&file_path, content)
171 .with_context(|| format!("failed to write temp file: {}", file_path.display()))?;
172 Ok(Self {
173 _dir: dir,
174 file_path,
175 })
176 }
177}
178
179#[allow(clippy::missing_panics_doc)]
190pub async fn run(args: ExplainArgs, global: &CLIGlobalOptions) -> Result<bool> {
191 let has_flag = args.file.is_some() || args.resolve_path.is_some() || args.schema.is_some();
194 let mut args = args;
195
196 let schema_fragment = if let Some(schema) = args.schema.take() {
199 let (base, frag) = split_schema_fragment(&schema);
200 args.schema = Some(base);
201 frag
202 } else {
203 None
204 };
205
206 let pointer_str = if has_flag {
207 if args.pointer.is_some() {
209 anyhow::bail!("unexpected extra positional argument");
210 }
211 args.positional.take().or(schema_fragment)
213 } else if args.positional.is_some() {
214 args.resolve_path = args.positional.take();
216 args.pointer.take()
217 } else {
218 anyhow::bail!(
219 "a file path or one of --file <FILE>, --path <FILE>, --schema <URL|FILE> is required"
220 );
221 };
222
223 let data_source_str = args.file.as_deref().or(args.resolve_path.as_deref());
224 let is_file_flag = args.file.is_some();
225
226 let fetched = fetch_data_source(data_source_str).await?;
227
228 let (schema_uri, display_name, is_remote) =
229 resolve_schema_info(&args, data_source_str, is_file_flag, fetched.as_ref()).await?;
230
231 let schema = fetch_schema(&schema_uri, is_remote, &args.cache).await?;
232 let schema_value = jsonschema_schema::SchemaValue::Schema(Box::new(schema));
233
234 let pointer = pointer_str
235 .as_deref()
236 .map(path::to_schema_pointer)
237 .transpose()
238 .map_err(|e| anyhow::anyhow!("{e}"))?;
239
240 let instance_prefix = pointer
241 .as_deref()
242 .map(schema_pointer_to_instance_prefix)
243 .unwrap_or_default();
244
245 let validation_errors = run_validation(
246 fetched.as_ref(),
247 data_source_str,
248 &args.cache,
249 &instance_prefix,
250 )
251 .await?;
252
253 let is_tty = std::io::stdout().is_terminal();
254 let use_color = global.use_color(is_tty);
255 let opts = jsonschema_explain::ExplainOptions {
256 color: use_color,
257 syntax_highlight: use_color && !args.no_syntax_highlighting,
258 width: lintel_cli_common::terminal_width(),
259 validation_errors,
260 extended: args.extended,
261 };
262
263 let display_name = if let Some(ref ptr) = pointer {
266 ptr.rsplit('/')
267 .next()
268 .filter(|s| !s.is_empty())
269 .unwrap_or(&display_name)
270 .to_string()
271 } else {
272 display_name
273 };
274
275 let output = match pointer.as_deref() {
276 Some(ptr) => jsonschema_explain::explain_at_path(&schema_value, ptr, &display_name, &opts)
277 .map_err(|e| anyhow::anyhow!("{e}"))?,
278 None => jsonschema_explain::explain(&schema_value, &display_name, &opts),
279 };
280
281 if is_tty && !args.no_pager {
282 lintel_cli_common::pipe_to_pager(&output);
283 } else {
284 print!("{output}");
285 }
286
287 Ok(false)
288}
289
290pub struct ExplainDisplayArgs {
296 pub no_syntax_highlighting: bool,
298 pub no_pager: bool,
300 pub extended: bool,
302}
303
304pub async fn explain_resolved_schema(
311 resolved: &ResolvedFileSchema,
312 cache: &CliCacheOptions,
313 global: &CLIGlobalOptions,
314 display: &ExplainDisplayArgs,
315) -> Result<()> {
316 match fetch_schema(&resolved.schema_uri, resolved.is_remote, cache).await {
317 Ok(schema) => {
318 let sv = jsonschema_schema::SchemaValue::Schema(Box::new(schema));
319 let is_tty = std::io::stdout().is_terminal();
320 let use_color = global.use_color(is_tty);
321 let opts = jsonschema_explain::ExplainOptions {
322 color: use_color,
323 syntax_highlight: use_color && !display.no_syntax_highlighting,
324 width: lintel_cli_common::terminal_width(),
325 validation_errors: vec![],
326 extended: display.extended,
327 };
328 let output = jsonschema_explain::explain(&sv, &resolved.display_name, &opts);
329 if is_tty && !display.no_pager {
330 lintel_cli_common::pipe_to_pager(&format!("\n{output}"));
331 } else {
332 println!();
333 print!("{output}");
334 }
335 Ok(())
336 }
337 Err(e) => {
338 eprintln!(" error fetching schema: {e}");
339 Ok(())
340 }
341 }
342}
343
344async fn fetch_data_source(data_source_str: Option<&str>) -> Result<Option<FetchedData>> {
346 let Some(src) = data_source_str else {
347 return Ok(None);
348 };
349 if !is_url(src) {
350 return Ok(None);
351 }
352 let content = fetch_url_content(src).await?;
353 let filename = url_filename(src);
354 Ok(Some(FetchedData { content, filename }))
355}
356
357async fn resolve_schema_info(
359 args: &ExplainArgs,
360 data_source_str: Option<&str>,
361 is_file_flag: bool,
362 fetched: Option<&FetchedData>,
363) -> Result<(String, String, bool)> {
364 if let Some(ref schema) = args.schema {
365 let is_remote = is_url(schema);
366 if !is_remote && !is_url(data_source_str.unwrap_or("")) {
367 let resolved = data_source_str
368 .map(Path::new)
369 .and_then(|p| p.parent())
370 .map_or_else(
371 || schema.clone(),
372 |parent| parent.join(schema).to_string_lossy().to_string(),
373 );
374 Ok((resolved.clone(), resolved, false))
375 } else {
376 Ok((schema.clone(), schema.clone(), is_remote))
377 }
378 } else if let Some(fetched) = fetched {
379 let cwd = std::env::current_dir().ok();
380 let virtual_path = PathBuf::from(&fetched.filename);
381 let resolved = resolve::resolve_schema_for_content(
382 &fetched.content,
383 &virtual_path,
384 cwd.as_deref(),
385 &args.cache,
386 )
387 .await?
388 .ok_or_else(|| {
389 anyhow::anyhow!("no schema found for URL: {}", data_source_str.unwrap_or(""))
390 })?;
391 Ok((
392 resolved.schema_uri,
393 resolved.display_name,
394 resolved.is_remote,
395 ))
396 } else if let Some(src) = data_source_str {
397 resolve_local_schema(src, is_file_flag, &args.cache).await
398 } else {
399 unreachable!("at least --schema is set (checked above)")
400 }
401}
402
403async fn resolve_local_schema(
405 src: &str,
406 is_file_flag: bool,
407 cache: &CliCacheOptions,
408) -> Result<(String, String, bool)> {
409 let path = Path::new(src);
410 if path.exists() {
411 let resolved = resolve::resolve_schema_for_file(path, cache)
412 .await?
413 .ok_or_else(|| anyhow::anyhow!("no schema found for {src}"))?;
414 Ok((
415 resolved.schema_uri,
416 resolved.display_name,
417 resolved.is_remote,
418 ))
419 } else if is_file_flag {
420 anyhow::bail!("file not found: {src}");
421 } else {
422 let resolved = resolve::resolve_schema_for_path(path, cache)
423 .await?
424 .ok_or_else(|| anyhow::anyhow!("no schema found for path: {src}"))?;
425 Ok((
426 resolved.schema_uri,
427 resolved.display_name,
428 resolved.is_remote,
429 ))
430 }
431}
432
433async fn run_validation(
435 fetched: Option<&FetchedData>,
436 data_source_str: Option<&str>,
437 cache: &CliCacheOptions,
438 instance_prefix: &str,
439) -> Result<Vec<jsonschema_explain::ExplainError>> {
440 if let Some(fetched) = fetched {
441 let temp = TempDataFile::new(&fetched.filename, &fetched.content)?;
442 let config_dir = std::env::current_dir().ok();
443 Ok(collect_validation_errors(
444 &temp.file_path.to_string_lossy(),
445 cache,
446 instance_prefix,
447 config_dir,
448 )
449 .await)
450 } else if let Some(src) = data_source_str {
451 if Path::new(src).exists() {
452 Ok(collect_validation_errors(src, cache, instance_prefix, None).await)
453 } else {
454 Ok(vec![])
455 }
456 } else {
457 Ok(vec![])
458 }
459}
460
461async fn fetch_schema(
462 schema_uri: &str,
463 is_remote: bool,
464 cache: &CliCacheOptions,
465) -> Result<jsonschema_schema::Schema> {
466 let retriever = resolve::build_retriever(cache);
467 let mut value: serde_json::Value = if is_remote {
468 let (val, _) = retriever
469 .fetch(schema_uri)
470 .await
471 .map_err(|e| anyhow::anyhow!("failed to fetch schema '{schema_uri}': {e}"))?;
472 val
473 } else {
474 let content = std::fs::read_to_string(schema_uri)
475 .with_context(|| format!("failed to read schema: {schema_uri}"))?;
476 serde_json::from_str(&content)
477 .with_context(|| format!("failed to parse schema: {schema_uri}"))?
478 };
479
480 inline::inline_external_refs(&mut value, schema_uri, &retriever).await?;
481 jsonschema_migrate::migrate_to_2020_12(&mut value);
482 let json_string = serde_json::to_string(&value)
483 .with_context(|| format!("failed to serialize schema: {schema_uri}"))?;
484 let mut jd = serde_json::Deserializer::from_str(&json_string);
485 serde_path_to_error::deserialize(&mut jd)
486 .with_context(|| format!("failed to deserialize schema: {schema_uri}"))
487}
488
489fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
492 let mut result = String::new();
493 let mut segments = schema_pointer.split('/').peekable();
494 segments.next();
496 while let Some(seg) = segments.next() {
497 if seg == "properties" {
498 if let Some(prop) = segments.next() {
500 result.push('/');
501 result.push_str(prop);
502 }
503 } else if seg == "items" {
504 } else {
506 result.push('/');
507 result.push_str(seg);
508 }
509 }
510 result
511}
512
513async fn collect_validation_errors(
516 file_path: &str,
517 cache: &CliCacheOptions,
518 instance_prefix: &str,
519 config_dir: Option<PathBuf>,
520) -> Vec<jsonschema_explain::ExplainError> {
521 let validate_args = lintel_validate::validate::ValidateArgs {
522 globs: vec![file_path.to_string()],
523 exclude: vec![],
524 cache_dir: cache.cache_dir.clone(),
525 force_schema_fetch: cache.force_schema_fetch || cache.force,
526 force_validation: false,
527 no_catalog: cache.no_catalog,
528 config_dir,
529 schema_cache_ttl: cache.schema_cache_ttl,
530 };
531
532 let result = match lintel_validate::validate::run(&validate_args).await {
533 Ok(r) => r,
534 Err(e) => {
535 tracing::debug!("validation failed: {e}");
536 return vec![];
537 }
538 };
539
540 result
541 .errors
542 .into_iter()
543 .filter_map(|err| {
544 if let lintel_diagnostics::LintelDiagnostic::Validation(v) = err {
545 if instance_prefix.is_empty()
548 || v.instance_path == instance_prefix
549 || v.instance_path.starts_with(&format!("{instance_prefix}/"))
550 {
551 Some(jsonschema_explain::ExplainError {
552 instance_path: v.instance_path,
553 message: v.message,
554 })
555 } else {
556 None
557 }
558 } else {
559 None
560 }
561 })
562 .collect()
563}
564
565#[cfg(test)]
566#[allow(clippy::unwrap_used)]
567mod tests {
568 use super::*;
569 use bpaf::Parser;
570 use lintel_cli_common::cli_global_options;
571
572 fn test_cli() -> bpaf::OptionParser<(CLIGlobalOptions, ExplainArgs)> {
573 bpaf::construct!(cli_global_options(), explain_args())
574 .to_options()
575 .descr("test explain args")
576 }
577
578 #[test]
579 fn cli_parses_schema_only() -> anyhow::Result<()> {
580 let (_, args) = test_cli()
581 .run_inner(&["--schema", "https://example.com/schema.json"])
582 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
583 assert_eq!(
584 args.schema.as_deref(),
585 Some("https://example.com/schema.json")
586 );
587 assert!(args.file.is_none());
588 assert!(args.positional.is_none());
589 Ok(())
590 }
591
592 #[test]
593 fn cli_parses_file_with_pointer() -> anyhow::Result<()> {
594 let (_, args) = test_cli()
595 .run_inner(&["--file", "config.yaml", "/properties/name"])
596 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
597 assert_eq!(args.file.as_deref(), Some("config.yaml"));
598 assert_eq!(args.positional.as_deref(), Some("/properties/name"));
599 Ok(())
600 }
601
602 #[test]
603 fn cli_parses_schema_with_jsonpath() -> anyhow::Result<()> {
604 let (_, args) = test_cli()
605 .run_inner(&["--schema", "schema.json", "$.name"])
606 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
607 assert_eq!(args.schema.as_deref(), Some("schema.json"));
608 assert_eq!(args.positional.as_deref(), Some("$.name"));
609 Ok(())
610 }
611
612 #[test]
613 fn cli_parses_display_options() -> anyhow::Result<()> {
614 let (_, args) = test_cli()
615 .run_inner(&[
616 "--schema",
617 "s.json",
618 "--no-syntax-highlighting",
619 "--no-pager",
620 ])
621 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
622 assert!(args.no_syntax_highlighting);
623 assert!(args.no_pager);
624 Ok(())
625 }
626
627 #[test]
628 fn cli_parses_path_only() -> anyhow::Result<()> {
629 let (_, args) = test_cli()
630 .run_inner(&["--path", "tsconfig.json"])
631 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
632 assert_eq!(args.resolve_path.as_deref(), Some("tsconfig.json"));
633 assert!(args.file.is_none());
634 assert!(args.schema.is_none());
635 assert!(args.positional.is_none());
636 Ok(())
637 }
638
639 #[test]
640 fn cli_parses_path_with_pointer() -> anyhow::Result<()> {
641 let (_, args) = test_cli()
642 .run_inner(&["--path", "config.yaml", "/properties/name"])
643 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
644 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
645 assert_eq!(args.positional.as_deref(), Some("/properties/name"));
646 Ok(())
647 }
648
649 #[test]
650 fn cli_parses_path_with_jsonpath() -> anyhow::Result<()> {
651 let (_, args) = test_cli()
652 .run_inner(&["--path", "config.yaml", "$.name"])
653 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
654 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
655 assert_eq!(args.positional.as_deref(), Some("$.name"));
656 Ok(())
657 }
658
659 #[test]
660 fn cli_file_takes_precedence_over_path() -> anyhow::Result<()> {
661 let (_, args) = test_cli()
662 .run_inner(&["--file", "data.yaml", "--path", "other.yaml"])
663 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
664 assert_eq!(args.file.as_deref(), Some("data.yaml"));
665 assert_eq!(args.resolve_path.as_deref(), Some("other.yaml"));
666 Ok(())
668 }
669
670 #[test]
671 fn cli_path_takes_precedence_over_schema() -> anyhow::Result<()> {
672 let (_, args) = test_cli()
673 .run_inner(&["--path", "config.yaml", "--schema", "s.json"])
674 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
675 assert_eq!(args.resolve_path.as_deref(), Some("config.yaml"));
676 assert_eq!(args.schema.as_deref(), Some("s.json"));
677 Ok(())
679 }
680
681 #[test]
682 fn cli_schema_with_file() -> anyhow::Result<()> {
683 let (_, args) = test_cli()
684 .run_inner(&["--schema", "s.json", "--file", "data.yaml"])
685 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
686 assert_eq!(args.schema.as_deref(), Some("s.json"));
687 assert_eq!(args.file.as_deref(), Some("data.yaml"));
688 Ok(())
689 }
690
691 #[test]
692 fn cli_schema_with_path() -> anyhow::Result<()> {
693 let (_, args) = test_cli()
694 .run_inner(&["--schema", "s.json", "--path", "data.yaml"])
695 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
696 assert_eq!(args.schema.as_deref(), Some("s.json"));
697 assert_eq!(args.resolve_path.as_deref(), Some("data.yaml"));
698 Ok(())
699 }
700
701 #[tokio::test]
702 async fn run_rejects_no_source() {
703 let args = ExplainArgs {
704 schema: None,
705 file: None,
706 resolve_path: None,
707 cache: CliCacheOptions {
708 cache_dir: None,
709 schema_cache_ttl: None,
710 force_schema_fetch: false,
711 force_validation: false,
712 force: false,
713 no_catalog: false,
714 },
715 no_syntax_highlighting: false,
716 no_pager: false,
717 extended: false,
718 positional: None,
719 pointer: None,
720 };
721 let global = CLIGlobalOptions {
722 colors: None,
723 verbose: false,
724 log_level: lintel_cli_common::LogLevel::None,
725 };
726 let err = run(args, &global).await.unwrap_err();
727 assert!(
728 err.to_string().contains("a file path or one of --file"),
729 "unexpected error: {err}"
730 );
731 }
732
733 #[test]
734 fn cli_parses_cache_options() -> anyhow::Result<()> {
735 let (_, args) = test_cli()
736 .run_inner(&[
737 "--schema",
738 "s.json",
739 "--cache-dir",
740 "/tmp/cache",
741 "--no-catalog",
742 ])
743 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
744 assert_eq!(args.cache.cache_dir.as_deref(), Some("/tmp/cache"));
745 assert!(args.cache.no_catalog);
746 Ok(())
747 }
748
749 #[test]
752 fn cli_positional_file_only() -> anyhow::Result<()> {
753 let (_, args) = test_cli()
754 .run_inner(&["package.json"])
755 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
756 assert_eq!(args.positional.as_deref(), Some("package.json"));
757 assert!(args.pointer.is_none());
758 assert!(args.file.is_none());
759 assert!(args.resolve_path.is_none());
760 assert!(args.schema.is_none());
761 Ok(())
762 }
763
764 #[test]
765 fn cli_positional_file_with_pointer() -> anyhow::Result<()> {
766 let (_, args) = test_cli()
767 .run_inner(&["package.json", "name"])
768 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
769 assert_eq!(args.positional.as_deref(), Some("package.json"));
770 assert_eq!(args.pointer.as_deref(), Some("name"));
771 assert!(args.file.is_none());
772 assert!(args.resolve_path.is_none());
773 assert!(args.schema.is_none());
774 Ok(())
775 }
776
777 #[test]
778 fn cli_positional_file_with_json_pointer() -> anyhow::Result<()> {
779 let (_, args) = test_cli()
780 .run_inner(&["config.yaml", "/properties/name"])
781 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
782 assert_eq!(args.positional.as_deref(), Some("config.yaml"));
783 assert_eq!(args.pointer.as_deref(), Some("/properties/name"));
784 Ok(())
785 }
786
787 #[test]
790 fn url_filename_simple() {
791 assert_eq!(
792 url_filename("https://example.com/package.json"),
793 "package.json"
794 );
795 }
796
797 #[test]
798 fn url_filename_with_query() {
799 assert_eq!(
800 url_filename("https://example.com/config.yaml?ref=main"),
801 "config.yaml"
802 );
803 }
804
805 #[test]
806 fn url_filename_with_fragment() {
807 assert_eq!(
808 url_filename("https://example.com/config.yaml#section"),
809 "config.yaml"
810 );
811 }
812
813 #[test]
814 fn url_filename_nested_path() {
815 assert_eq!(
816 url_filename(
817 "https://raw.githubusercontent.com/org/repo/main/.github/workflows/ci.yml"
818 ),
819 "ci.yml"
820 );
821 }
822
823 #[test]
824 fn url_filename_trailing_slash() {
825 assert_eq!(url_filename("https://example.com/"), "file");
826 }
827
828 #[test]
831 fn is_url_detects_https() {
832 assert!(is_url("https://example.com/schema.json"));
833 }
834
835 #[test]
836 fn is_url_detects_http() {
837 assert!(is_url("http://example.com/schema.json"));
838 }
839
840 #[test]
841 fn is_url_rejects_local() {
842 assert!(!is_url("./schema.json"));
843 assert!(!is_url("/tmp/schema.json"));
844 assert!(!is_url("schema.json"));
845 }
846
847 #[test]
850 fn fragment_extracted_from_url() {
851 let (base, frag) = split_schema_fragment("https://example.com/s.json#/$defs/Foo");
852 assert_eq!(base, "https://example.com/s.json");
853 assert_eq!(frag.as_deref(), Some("/$defs/Foo"));
854 }
855
856 #[test]
857 fn no_fragment_returns_none() {
858 let (base, frag) = split_schema_fragment("https://example.com/s.json");
859 assert_eq!(base, "https://example.com/s.json");
860 assert_eq!(frag, None);
861 }
862
863 #[test]
864 fn empty_fragment_returns_none() {
865 let (base, frag) = split_schema_fragment("https://example.com/s.json#");
866 assert_eq!(base, "https://example.com/s.json");
867 assert_eq!(frag, None);
868 }
869
870 #[test]
871 fn local_file_with_fragment() {
872 let (base, frag) = split_schema_fragment("./schema.json#/$defs/Bar");
873 assert_eq!(base, "./schema.json");
874 assert_eq!(frag.as_deref(), Some("/$defs/Bar"));
875 }
876}