use std::env;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
#[cfg(feature = "remote-schema")]
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use schemaui::{
DocumentFormat, DocumentFormatProbe, looks_like_json_schema, parse_document_str,
schema_from_data_value,
};
use serde_json::Value;
#[cfg(feature = "remote-schema")]
use ureq::Agent;
use url::Url;
use super::diagnostics::DiagnosticCollector;
#[derive(Debug, Clone)]
pub(crate) enum DocumentOrigin {
File(PathBuf),
#[cfg(feature = "remote-schema")]
Url(Url),
Inline,
Stdin,
}
#[derive(Debug, Clone)]
pub(crate) struct LoadedDocument {
pub value: Value,
#[cfg(any(feature = "yaml", feature = "toml"))]
pub raw: String,
pub format: DocumentFormat,
pub origin: DocumentOrigin,
}
#[derive(Debug)]
pub(crate) struct ResolvedSessionInputs {
pub schema: Value,
pub defaults: Option<Value>,
}
pub(crate) fn load_optional_document(
spec: Option<&str>,
format: DocumentFormat,
label: &str,
skip: bool,
diagnostics: &mut DiagnosticCollector,
) -> Option<LoadedDocument> {
if skip {
return None;
}
let spec = spec?;
match load_document(spec, format, label) {
Ok(document) => Some(document),
Err(err) => {
diagnostics.push_input(label, err.to_string());
None
}
}
}
pub(crate) fn load_document(
spec: &str,
format: DocumentFormat,
label: &str,
) -> Result<LoadedDocument> {
if spec == "-" {
let raw = read_stdin()?;
let value = parse_contents(&raw, format, label)?;
return Ok(LoadedDocument {
value,
#[cfg(any(feature = "yaml", feature = "toml"))]
raw,
format,
origin: DocumentOrigin::Stdin,
});
}
if let Some(url) = parse_special_url(spec) {
return load_document_from_url(url, format, label);
}
let path = PathBuf::from(spec);
match fs::read_to_string(&path) {
Ok(raw) => {
let value = parse_contents(&raw, format, label)?;
Ok(LoadedDocument {
value,
#[cfg(any(feature = "yaml", feature = "toml"))]
raw,
format,
origin: DocumentOrigin::File(path),
})
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if should_treat_missing_path_as_inline_payload(spec) {
let inline_label = format!("inline {label}");
let value = parse_contents(spec, format, &inline_label)?;
Ok(LoadedDocument {
value,
#[cfg(any(feature = "yaml", feature = "toml"))]
raw: spec.to_string(),
format,
origin: DocumentOrigin::Inline,
})
} else {
Err(anyhow!(err).context(format!("failed to load {label} from {}", path.display())))
}
}
Err(err) => {
Err(anyhow!(err).context(format!("failed to load {label} from {}", path.display())))
}
}
}
pub(crate) fn resolve_session_inputs(
explicit_schema: Option<LoadedDocument>,
config: Option<LoadedDocument>,
) -> Result<ResolvedSessionInputs> {
let explicit_schema = explicit_schema.map(|document| document.value);
if explicit_schema.is_none()
&& let Some(config) = config.as_ref()
&& looks_like_json_schema(&config.value)
{
eprintln!("detected JSON Schema provided via --config; treating it as the active schema");
return Ok(ResolvedSessionInputs {
schema: config.value.clone(),
defaults: None,
});
}
let config = config.map(PreparedConfig::from_loaded).transpose()?;
let schema = if let Some(schema) = explicit_schema {
schema
} else if let Some(reference) = config
.as_ref()
.and_then(|config| config.declared_schema.as_deref())
{
load_schema_reference(reference, config.as_ref().map(|config| &config.origin))?
} else if let Some(config) = config.as_ref() {
schema_from_data_value(&config.value)
} else {
return Err(anyhow!("provide at least --schema or --config"));
};
Ok(ResolvedSessionInputs {
schema,
defaults: config.map(|config| config.value),
})
}
#[derive(Debug)]
struct PreparedConfig {
value: Value,
declared_schema: Option<String>,
origin: DocumentOrigin,
}
impl PreparedConfig {
fn from_loaded(document: LoadedDocument) -> Result<Self> {
let declared_schema = detect_declared_schema(&document);
let value = match document.format {
#[cfg(feature = "json")]
DocumentFormat::Json if declared_schema.is_some() => {
strip_root_json_schema_declaration(document.value)
}
_ => document.value,
};
Ok(Self {
value,
declared_schema,
origin: document.origin,
})
}
}
fn load_schema_reference(reference: &str, base: Option<&DocumentOrigin>) -> Result<Value> {
let location = resolve_schema_reference(reference, base)?;
let format = format_for_reference_location(&location)?;
match location {
ReferenceLocation::File(path) => {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read schema file {}", path.display()))?;
parse_contents(&raw, format, "schema")
}
ReferenceLocation::Url(url) => {
let document = load_document_from_url(url, format, "schema")?;
Ok(document.value)
}
}
}
fn resolve_schema_reference(
reference: &str,
base: Option<&DocumentOrigin>,
) -> Result<ReferenceLocation> {
if let Some(url) = parse_special_url(reference) {
return match url.scheme() {
"file" => {
let path = url
.to_file_path()
.map_err(|_| anyhow!("invalid file:// schema reference: {url}"))?;
Ok(ReferenceLocation::File(path))
}
_ => Ok(ReferenceLocation::Url(url)),
};
}
let reference_path = PathBuf::from(reference);
if reference_path.is_absolute() {
return Ok(ReferenceLocation::File(reference_path));
}
match base {
Some(DocumentOrigin::File(path)) => {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
Ok(ReferenceLocation::File(parent.join(reference_path)))
}
#[cfg(feature = "remote-schema")]
Some(DocumentOrigin::Url(url)) => {
let joined = url
.join(reference)
.with_context(|| format!("failed to resolve schema reference '{reference}'"))?;
if joined.scheme() == "file" {
let path = joined
.to_file_path()
.map_err(|_| anyhow!("invalid file:// schema reference: {joined}"))?;
Ok(ReferenceLocation::File(path))
} else {
Ok(ReferenceLocation::Url(joined))
}
}
Some(DocumentOrigin::Inline) | Some(DocumentOrigin::Stdin) | None => {
let cwd = env::current_dir().context("failed to resolve current working directory")?;
Ok(ReferenceLocation::File(cwd.join(reference_path)))
}
}
}
#[derive(Debug)]
enum ReferenceLocation {
File(PathBuf),
Url(Url),
}
fn format_for_reference_location(location: &ReferenceLocation) -> Result<DocumentFormat> {
let probe = match location {
ReferenceLocation::File(path) => DocumentFormat::probe_extension(path),
ReferenceLocation::Url(url) => DocumentFormat::probe_extension(Path::new(url.path())),
};
match probe {
DocumentFormatProbe::Known(format) => Ok(format),
DocumentFormatProbe::UnsupportedFeature {
format_name,
feature_flag,
} => Err(anyhow!(
"schema reference {} requires {format_name} support, but this build lacks the '{feature_flag}' feature",
reference_location_label(location)
)),
DocumentFormatProbe::Unknown => Ok(DocumentFormat::default()),
}
}
fn detect_declared_schema(document: &LoadedDocument) -> Option<String> {
match document.format {
#[cfg(feature = "json")]
DocumentFormat::Json => document
.value
.as_object()
.and_then(|object| object.get("$schema"))
.and_then(Value::as_str)
.map(str::to_string),
#[cfg(feature = "yaml")]
DocumentFormat::Yaml => detect_yaml_schema_directive(&document.raw),
#[cfg(feature = "toml")]
DocumentFormat::Toml => detect_toml_schema_directive(&document.raw),
}
}
#[cfg(feature = "yaml")]
fn detect_yaml_schema_directive(raw: &str) -> Option<String> {
for (index, line) in raw.lines().enumerate() {
let trimmed = if index == 0 {
line.trim_start_matches('\u{feff}').trim()
} else {
line.trim()
};
if trimmed.is_empty() {
continue;
}
let Some(comment) = trimmed.strip_prefix('#') else {
break;
};
let comment = comment.trim();
if let Some(rest) = comment.strip_prefix("yaml-language-server:") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix("$schema") {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim();
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
if let Some(rest) = comment.strip_prefix("@schema") {
let rest = rest.trim();
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
None
}
#[cfg(feature = "toml")]
fn detect_toml_schema_directive(raw: &str) -> Option<String> {
for (index, line) in raw.lines().enumerate() {
let trimmed = if index == 0 {
line.trim_start_matches('\u{feff}').trim()
} else {
line.trim()
};
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("#:schema") {
let rest = rest.trim();
if !rest.is_empty() {
return Some(rest.to_string());
}
}
if trimmed.starts_with('#') {
continue;
}
break;
}
None
}
#[cfg(feature = "json")]
fn strip_root_json_schema_declaration(value: Value) -> Value {
match value {
Value::Object(mut object) => {
object.remove("$schema");
Value::Object(object)
}
other => other,
}
}
fn load_document_from_url(url: Url, format: DocumentFormat, label: &str) -> Result<LoadedDocument> {
match url.scheme() {
"file" => {
let path = url
.to_file_path()
.map_err(|_| anyhow!("invalid file:// URL for {label}: {url}"))?;
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {label} file {}", path.display()))?;
let value = parse_contents(&raw, format, label)?;
Ok(LoadedDocument {
value,
#[cfg(any(feature = "yaml", feature = "toml"))]
raw,
format,
origin: DocumentOrigin::File(path),
})
}
#[cfg(feature = "remote-schema")]
"http" | "https" => {
let mut config = Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)));
if should_bypass_proxy(&url) {
config = config.proxy(None);
}
let client = Agent::new_with_config(config.build());
let mut response = client
.get(url.as_str())
.call()
.with_context(|| format!("failed to fetch {label} from {url}"))?;
let status = response.status();
if !status.is_success() {
return Err(anyhow!(
"failed to fetch {label} from {url}: HTTP {}",
status.as_u16()
));
}
let raw = response
.body_mut()
.read_to_string()
.with_context(|| format!("failed to read {label} response body from {url}"))?;
let value = parse_contents(&raw, format, label)?;
Ok(LoadedDocument {
value,
#[cfg(any(feature = "yaml", feature = "toml"))]
raw,
format,
#[cfg(feature = "remote-schema")]
origin: DocumentOrigin::Url(url),
#[cfg(not(feature = "remote-schema"))]
origin: DocumentOrigin::Inline,
})
}
#[cfg(not(feature = "remote-schema"))]
"http" | "https" => Err(anyhow!(
"remote schema support is disabled in this build; re-enable the 'remote-schema' feature to load {label} from {url}"
)),
_ => Err(anyhow!("unsupported URL scheme for {label}: {url}")),
}
}
fn parse_special_url(spec: &str) -> Option<Url> {
let url = Url::parse(spec).ok()?;
match url.scheme() {
"http" | "https" | "file" => Some(url),
_ => None,
}
}
fn should_treat_missing_path_as_inline_payload(spec: &str) -> bool {
let trimmed = spec.trim();
!trimmed.is_empty()
&& (looks_like_structured_inline_payload(trimmed) || !looks_like_file_path(trimmed))
}
fn looks_like_structured_inline_payload(spec: &str) -> bool {
spec.contains('\n')
|| spec.starts_with('{')
|| spec.starts_with('[')
|| spec.starts_with('"')
|| spec.starts_with('\'')
|| spec.starts_with("---")
|| spec.starts_with('#')
|| spec.contains(": ")
|| spec.contains(":\n")
|| spec.contains(" = ")
|| spec.contains("=\n")
}
fn looks_like_file_path(spec: &str) -> bool {
let path = Path::new(spec);
spec.starts_with('.')
|| spec.starts_with('~')
|| spec.contains(std::path::MAIN_SEPARATOR)
|| path.extension().is_some()
}
#[cfg(feature = "remote-schema")]
fn should_bypass_proxy(url: &Url) -> bool {
matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
}
fn read_stdin() -> Result<String> {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.context("failed to read from stdin")?;
Ok(buffer)
}
fn parse_contents(contents: &str, format: DocumentFormat, label: &str) -> Result<Value> {
match parse_document_str(contents, format) {
Ok(value) => Ok(value),
Err(primary) => {
for candidate in DocumentFormat::available_formats() {
if candidate == format {
continue;
}
if let Ok(value) = parse_document_str(contents, candidate) {
return Ok(value);
}
}
Err(anyhow!(
"failed to parse {label}: tried {} (first error: {primary})",
DocumentFormat::format_list()
))
}
}
}
fn reference_location_label(location: &ReferenceLocation) -> String {
match location {
ReferenceLocation::File(path) => path.display().to_string(),
ReferenceLocation::Url(url) => url.to_string(),
}
}