Skip to main content

lintel_explain/
lib.rs

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// ---------------------------------------------------------------------------
20// CLI args
21// ---------------------------------------------------------------------------
22
23#[derive(Debug, Clone, Bpaf)]
24#[bpaf(generate(explain_args_inner))]
25pub struct ExplainArgs {
26    /// Schema URL or local file path to explain.
27    /// Can be combined with `--file` or `--path` to override schema resolution
28    /// while still validating the data file.
29    #[bpaf(long("schema"), argument("URL|FILE"), complete_shell(ShellComp::File { mask: None }))]
30    pub schema: Option<String>,
31
32    /// Data file (local path or URL) to resolve the schema from and validate.
33    /// The file must exist (or be fetchable). For URLs, the filename is used
34    /// for catalog matching.
35    #[bpaf(long("file"), argument("FILE|URL"), complete_shell(ShellComp::File { mask: None }))]
36    pub file: Option<String>,
37
38    /// File path or URL to resolve the schema from using catalogs.
39    /// Local files need not exist; if the file exists (or is a URL), it is
40    /// also validated.
41    #[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    /// Disable syntax highlighting in code blocks
48    #[bpaf(long("no-syntax-highlighting"), switch)]
49    pub no_syntax_highlighting: bool,
50
51    /// Print output directly instead of piping through a pager
52    #[bpaf(long("no-pager"), switch)]
53    pub no_pager: bool,
54
55    /// Show extended details like $comment annotations
56    #[bpaf(long("extended"), switch)]
57    pub extended: bool,
58
59    /// First positional argument. When no `--file`, `--path`, or `--schema`
60    /// flag is given this is treated as a file path (equivalent to `--path`).
61    /// Otherwise it is a JSON Pointer or `JSONPath` to a sub-schema.
62    ///
63    /// Examples:
64    /// - `lintel explain package.json`       — explains the schema for `package.json`
65    /// - `lintel explain --file f.yaml name`  — explains the `name` property
66    #[bpaf(positional("FILE|POINTER"), complete_shell(ShellComp::File { mask: None }))]
67    pub positional: Option<String>,
68
69    /// JSON Pointer (`/properties/name`) or `JSONPath` (`$.name`) to a sub-schema.
70    /// Only used when the first positional is a file path.
71    ///
72    /// Example: `lintel explain package.json name`
73    #[bpaf(positional("POINTER"))]
74    pub pointer: Option<String>,
75}
76
77/// Construct the bpaf parser for `ExplainArgs`.
78pub fn explain_args() -> impl bpaf::Parser<ExplainArgs> {
79    explain_args_inner()
80}
81
82// ---------------------------------------------------------------------------
83// Helpers
84// ---------------------------------------------------------------------------
85
86fn is_url(s: &str) -> bool {
87    s.starts_with("http://") || s.starts_with("https://")
88}
89
90/// Split a schema URI into the base URL and an optional fragment pointer.
91///
92/// Uses the `url` crate for proper URL parsing when the input is a URL.
93/// For local file paths, falls back to simple `#` splitting.
94///
95/// e.g. `"https://example.com/s.json#/$defs/Foo"` → `("https://example.com/s.json", Some("/$defs/Foo"))`
96fn 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    // Local file path: simple split on '#'
109    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
122/// Extract the last path segment from a URL, e.g. "package.json".
123fn url_filename(url: &str) -> String {
124    url.rsplit('/')
125        .next()
126        .and_then(|seg| {
127            // Strip query string / fragment
128            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
139/// Fetch URL content via HTTP GET.
140async 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
153/// Data fetched from a remote URL.
154struct FetchedData {
155    content: String,
156    filename: String,
157}
158
159/// Temporary file used to give the validation pipeline a real file path with
160/// the correct filename for catalog matching. Cleaned up on drop.
161struct 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// ---------------------------------------------------------------------------
180// Entry point
181// ---------------------------------------------------------------------------
182
183/// Run the explain command.
184///
185/// # Errors
186///
187/// Returns an error if the schema cannot be fetched, parsed, or the pointer
188/// cannot be resolved.
189#[allow(clippy::missing_panics_doc)]
190pub async fn run(args: ExplainArgs, global: &CLIGlobalOptions) -> Result<bool> {
191    // Normalize positional args: when no flag is given, the first positional
192    // is a file path (equivalent to --path) and the second is the pointer.
193    let has_flag = args.file.is_some() || args.resolve_path.is_some() || args.schema.is_some();
194    let mut args = args;
195
196    // Extract fragment from --schema if present (e.g., URL#/$defs/Foo).
197    // The fragment is used as the pointer to navigate into the schema.
198    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        // Flags present: first positional is the pointer, second is invalid.
208        if args.pointer.is_some() {
209            anyhow::bail!("unexpected extra positional argument");
210        }
211        // Explicit positional pointer takes precedence over schema fragment.
212        args.positional.take().or(schema_fragment)
213    } else if args.positional.is_some() {
214        // No flags: first positional is the file path.
215        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    // When navigating via pointer, use the last path segment as the display
264    // name (e.g. "compilerOptionsDefinition" instead of the full URL).
265    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
290// ---------------------------------------------------------------------------
291// Shared explain helper for `lintel identify --explain`
292// ---------------------------------------------------------------------------
293
294/// Display options for rendering schema documentation.
295pub struct ExplainDisplayArgs {
296    /// Disable syntax highlighting in code blocks.
297    pub no_syntax_highlighting: bool,
298    /// Print output directly instead of piping through a pager.
299    pub no_pager: bool,
300    /// Show extended details like `$comment` annotations.
301    pub extended: bool,
302}
303
304/// Fetch, migrate, and render schema documentation for an already-resolved
305/// schema. Used by `lintel identify --explain`.
306///
307/// # Errors
308///
309/// Returns an error if the schema cannot be fetched or deserialized.
310pub 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
344/// If the data source is a URL, fetch its content; otherwise return `None`.
345async 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
357/// Determine the schema URI, display name, and whether it's remote.
358async 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
403/// Resolve schema from a local file or path.
404async 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
433/// Collect validation errors from the data source if available.
434async 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
489/// Convert a schema pointer (e.g. `/properties/badges`) to an instance path
490/// prefix (e.g. `/badges`) by stripping `/properties/` segments.
491fn schema_pointer_to_instance_prefix(schema_pointer: &str) -> String {
492    let mut result = String::new();
493    let mut segments = schema_pointer.split('/').peekable();
494    // Skip the leading empty segment from the leading `/`.
495    segments.next();
496    while let Some(seg) = segments.next() {
497        if seg == "properties" {
498            // The next segment is the actual property name.
499            if let Some(prop) = segments.next() {
500                result.push('/');
501                result.push_str(prop);
502            }
503        } else if seg == "items" {
504            // Array items — keep descending but don't add to the prefix.
505        } else {
506            result.push('/');
507            result.push_str(seg);
508        }
509    }
510    result
511}
512
513/// Run validation on a data file and return errors filtered to a given
514/// instance path prefix.
515async 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                // When explaining the root, show all errors.
546                // Otherwise only show errors under the given property.
547                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        // Both are parsed — precedence is enforced at runtime in run()
667        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        // Both are parsed — precedence is enforced at runtime in run()
678        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    // --- positional-only usage ---
750
751    #[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    // --- URL filename extraction ---
788
789    #[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    // --- is_url ---
829
830    #[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    // --- split_schema_fragment ---
848
849    #[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}