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, PathBuf};
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    /// Can be combined with `--file` or `--path` to override schema resolution
22    /// while still validating the data file.
23    #[bpaf(long("schema"), argument("URL|FILE"))]
24    pub schema: Option<String>,
25
26    /// Data file (local path or URL) to resolve the schema from and validate.
27    /// The file must exist (or be fetchable). For URLs, the filename is used
28    /// for catalog matching.
29    #[bpaf(long("file"), argument("FILE|URL"))]
30    pub file: Option<String>,
31
32    /// File path or URL to resolve the schema from using catalogs.
33    /// Local files need not exist; if the file exists (or is a URL), it is
34    /// also validated.
35    #[bpaf(long("path"), argument("FILE|URL"))]
36    pub resolve_path: Option<String>,
37
38    #[bpaf(external(lintel_cli_common::cli_cache_options))]
39    pub cache: CliCacheOptions,
40
41    /// Disable syntax highlighting in code blocks
42    #[bpaf(long("no-syntax-highlighting"), switch)]
43    pub no_syntax_highlighting: bool,
44
45    /// Print output directly instead of piping through a pager
46    #[bpaf(long("no-pager"), switch)]
47    pub no_pager: bool,
48
49    /// First positional argument. When no `--file`, `--path`, or `--schema`
50    /// flag is given this is treated as a file path (equivalent to `--path`).
51    /// Otherwise it is a JSON Pointer or `JSONPath` to a sub-schema.
52    ///
53    /// Examples:
54    /// - `lintel explain package.json`       — explains the schema for `package.json`
55    /// - `lintel explain --file f.yaml name`  — explains the `name` property
56    #[bpaf(positional("FILE|POINTER"))]
57    pub positional: Option<String>,
58
59    /// JSON Pointer (`/properties/name`) or `JSONPath` (`$.name`) to a sub-schema.
60    /// Only used when the first positional is a file path.
61    ///
62    /// Example: `lintel explain package.json name`
63    #[bpaf(positional("POINTER"))]
64    pub pointer: Option<String>,
65}
66
67/// Construct the bpaf parser for `ExplainArgs`.
68pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
69    explain_args_inner()
70}
71
72// ---------------------------------------------------------------------------
73// Helpers
74// ---------------------------------------------------------------------------
75
76fn is_url(s: &str) -> bool {
77    s.starts_with("http://") || s.starts_with("https://")
78}
79
80/// Extract the last path segment from a URL, e.g. "package.json".
81fn url_filename(url: &str) -> String {
82    url.rsplit('/')
83        .next()
84        .and_then(|seg| {
85            // Strip query string / fragment
86            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
97/// Fetch URL content via HTTP GET.
98async 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
111/// Data fetched from a remote URL.
112struct FetchedData {
113    content: String,
114    filename: String,
115}
116
117/// Temporary file used to give the validation pipeline a real file path with
118/// the correct filename for catalog matching. Cleaned up on drop.
119struct 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// ---------------------------------------------------------------------------
138// Entry point
139// ---------------------------------------------------------------------------
140
141/// Run the explain command.
142///
143/// # Errors
144///
145/// Returns an error if the schema cannot be fetched, parsed, or the pointer
146/// cannot be resolved.
147#[allow(clippy::missing_panics_doc)]
148pub async fn run(args: ExplainArgs, global: &CLIGlobalOptions) -> Result<bool> {
149    // Normalize positional args: when no flag is given, the first positional
150    // is a file path (equivalent to --path) and the second is the pointer.
151    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        // Flags present: first positional is the pointer, second is invalid.
155        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        // No flags: first positional is the file path.
161        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
208/// If the data source is a URL, fetch its content; otherwise return `None`.
209async 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
221/// Determine the schema URI, display name, and whether it's remote.
222async 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
267/// Resolve schema from a local file or path.
268async 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
297/// Collect validation errors from the data source if available.
298async 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/// Render the schema explanation output.
326#[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
386/// Convert a schema pointer (e.g. `/properties/badges`) to an instance path
387/// prefix (e.g. `/badges`) by stripping `/properties/` segments.
388fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
389    let mut result = String::new();
390    let mut segments = schema_pointer.split('/').peekable();
391    // Skip the leading empty segment from the leading `/`.
392    segments.next();
393    while let Some(seg) = segments.next() {
394        if seg == "properties" {
395            // The next segment is the actual property name.
396            if let Some(prop) = segments.next() {
397                result.push('/');
398                result.push_str(prop);
399            }
400        } else if seg == "items" {
401            // Array items — keep descending but don't add to the prefix.
402        } else {
403            result.push('/');
404            result.push_str(seg);
405        }
406    }
407    result
408}
409
410/// Run validation on a data file and return errors filtered to a given
411/// instance path prefix.
412async 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                // When explaining the root, show all errors.
448                // Otherwise only show errors under the given property.
449                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        // Both are parsed — precedence is enforced at runtime in run()
569        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        // Both are parsed — precedence is enforced at runtime in run()
580        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    // --- positional-only usage ---
651
652    #[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    // --- URL filename extraction ---
689
690    #[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    // --- is_url ---
730
731    #[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}