#![doc = include_str!("../cli_usage.md")]
use std::fmt::Write as FmtWrite;
use std::fs;
use std::io::{self, Read};
#[cfg(feature = "web")]
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use clap::{ArgAction, Parser, Subcommand};
use color_eyre::eyre::{Report, Result, WrapErr, eyre};
use serde_json::Value;
use schemaui::io::output;
#[cfg(feature = "web")]
use schemaui::web::session::{ServeOptions as WebServeOptions, WebSessionBuilder, bind_session};
use schemaui::{
DocumentFormat, OutputDestination, OutputOptions, SchemaUI, parse_document_str,
schema_from_data_value, schema_with_defaults,
};
#[cfg(feature = "web")]
use tokio::runtime::Runtime;
const DEFAULT_TEMP_FILE: &str = "/tmp/schemaui.json";
#[derive(Debug, Parser)]
#[command(
name = "schemaui",
version,
about = "Render JSON Schemas as interactive TUIs or Web UIs"
)]
struct Cli {
#[command(flatten)]
common: CommonArgs,
#[cfg(feature = "web")]
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Debug, Parser, Clone)]
struct CommonArgs {
#[arg(short = 's', long = "schema", value_name = "SPEC")]
schema: Option<String>,
#[arg(short = 'c', long = "config", alias = "data", value_name = "SPEC")]
config: Option<String>,
#[arg(long = "title", value_name = "TEXT")]
title: Option<String>,
#[arg(short = 'o', long = "output", value_name = "DEST", num_args = 1.., action = ArgAction::Append)]
outputs: Vec<String>,
#[arg(long = "temp-file", value_name = "PATH")]
temp_file: Option<PathBuf>,
#[arg(long = "no-temp-file")]
no_temp_file: bool,
#[arg(long = "no-pretty")]
no_pretty: bool,
#[arg(short = 'f', long = "force", short_alias = 'y', alias = "yes")]
force: bool,
}
#[cfg(feature = "web")]
#[derive(Debug, Subcommand)]
enum Commands {
Web(WebCommand),
}
#[cfg(feature = "web")]
#[derive(Debug, Parser, Clone)]
struct WebCommand {
#[command(flatten)]
common: CommonArgs,
#[arg(long = "host", value_name = "IP", default_value = "127.0.0.1")]
host: IpAddr,
#[arg(long = "port", value_name = "PORT", default_value_t = 0)]
port: u16,
}
#[derive(Debug)]
enum InputSource {
File(PathBuf),
Stdin,
}
#[derive(Debug)]
struct SessionBundle {
schema: Value,
defaults: Option<Value>,
title: Option<String>,
output: Option<OutputOptions>,
}
fn main() -> Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
#[cfg(feature = "web")]
if let Some(command) = cli.command {
return match command {
Commands::Web(args) => run_web_cli(args),
};
}
run_tui_cli(&cli.common)
}
fn run_tui_cli(args: &CommonArgs) -> Result<()> {
let session = prepare_session(args)?;
execute_tui_session(session)
}
fn execute_tui_session(session: SessionBundle) -> Result<()> {
let SessionBundle {
schema,
defaults,
title,
output,
} = session;
let mut ui = SchemaUI::new(schema);
if let Some(title) = title {
ui = ui.with_title(title);
}
if let Some(ref defaults) = defaults {
ui = ui.with_default_data(defaults);
}
if let Some(options) = output {
ui = ui.with_output(options);
}
ui.run().map_err(Report::msg).map(|_| ())
}
fn prepare_session(args: &CommonArgs) -> Result<SessionBundle> {
let mut diagnostics = DiagnosticCollector::default();
let schema_spec = args.schema.as_deref();
let config_spec = args.config.as_deref();
let schema_stdin = schema_spec == Some("-");
let config_stdin = config_spec == Some("-");
if schema_stdin && config_stdin {
diagnostics.push_input(
"schema/config",
"cannot read schema and config from stdin simultaneously; provide inline content or files",
);
}
let schema_hint = resolve_format_hint(schema_spec, "schema", &mut diagnostics);
let config_hint = resolve_format_hint(config_spec, "config", &mut diagnostics);
let schema_value = load_optional_value(
schema_spec,
schema_hint.hint.format,
"schema",
schema_hint.blocked || (schema_stdin && config_stdin),
&mut diagnostics,
);
let config_value = load_optional_value(
config_spec,
config_hint.hint.format,
"config",
config_hint.blocked || (schema_stdin && config_stdin),
&mut diagnostics,
);
let (output_settings, output_paths) = build_output_options(
args,
config_hint.hint.extension_value(),
schema_hint.hint.extension_value(),
&mut diagnostics,
);
ensure_output_paths_available(&output_paths, args.force, &mut diagnostics);
diagnostics.into_result()?;
let mut schema_value = schema_value;
let mut config_value = config_value;
if schema_value.is_none() {
if let Some(config_doc) = config_value.as_ref()
&& looks_like_json_schema(config_doc)
{
eprintln!(
"detected JSON Schema provided via --config; treating it as the active schema"
);
schema_value = config_value.take();
}
}
if schema_value.is_none() && config_value.is_none() {
return Err(eyre!("provide at least --schema or --config"));
}
let schema = match (schema_value, config_value.as_ref()) {
(Some(schema), Some(defaults)) => schema_with_defaults(&schema, defaults),
(Some(schema), None) => schema,
(None, Some(defaults)) => schema_from_data_value(defaults),
(None, None) => unreachable!("validated above"),
};
Ok(SessionBundle {
schema,
defaults: config_value,
title: args.title.clone(),
output: output_settings,
})
}
#[cfg(feature = "web")]
fn run_web_cli(cmd: WebCommand) -> Result<()> {
let session = prepare_session(&cmd.common)?;
let SessionBundle {
schema,
defaults,
title,
output,
} = session;
let mut builder = WebSessionBuilder::new(schema);
if let Some(title) = title.clone() {
builder = builder.with_title(title);
}
if let Some(defaults) = defaults {
builder = builder.with_initial_data(defaults);
}
let config = builder.build().map_err(|err| eyre!(err))?;
let runtime = Runtime::new().wrap_err("failed to initialize tokio runtime")?;
let host = cmd.host;
let port = cmd.port;
let value = runtime.block_on(async move {
let bound = bind_session(config, WebServeOptions { host, port })
.await
.map_err(|err| eyre!(err))?;
let addr = bound.local_addr();
eprintln!("schemaui web UI available at http://{addr}/");
eprintln!("Press Ctrl+C to abort the session.");
bound.run().await.map_err(|err| eyre!(err))
})?;
if let Some(options) = output {
output::emit(&value, &options).map_err(|err| eyre!(err))?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct FormatHint {
format: DocumentFormat,
from_extension: bool,
}
impl Default for FormatHint {
fn default() -> Self {
Self {
format: DocumentFormat::default(),
from_extension: false,
}
}
}
impl FormatHint {
fn extension_value(&self) -> Option<DocumentFormat> {
self.from_extension.then_some(self.format)
}
}
#[derive(Debug, Clone, Copy)]
struct FormatResolution {
hint: FormatHint,
blocked: bool,
}
fn resolve_format_hint(
path_hint: Option<&str>,
label: &str,
diagnostics: &mut DiagnosticCollector,
) -> FormatResolution {
if let Some(path) = path_hint {
if path != "-" {
match probe_format_from_extension(Path::new(path)) {
ExtensionFormat::Known(format) => {
return FormatResolution {
hint: FormatHint {
format,
from_extension: true,
},
blocked: false,
};
}
ExtensionFormat::UnsupportedFeature {
format_name,
feature_flag,
} => {
diagnostics.push_input(
label,
format!(
"{label} '{path}' requires {format_name} support, but this build lacks the '{feature_flag}' feature"
),
);
return FormatResolution {
hint: FormatHint::default(),
blocked: true,
};
}
ExtensionFormat::Unknown => {}
}
}
}
FormatResolution {
hint: FormatHint::default(),
blocked: false,
}
}
fn load_optional_value(
spec: Option<&str>,
format: DocumentFormat,
label: &str,
skip: bool,
diagnostics: &mut DiagnosticCollector,
) -> Option<Value> {
if skip {
return None;
}
let Some(raw) = spec else {
return None;
};
match load_value(raw, format, label) {
Ok(value) => Some(value),
Err(err) => {
diagnostics.push_input(label, err.to_string());
None
}
}
}
fn load_value(spec: &str, format: DocumentFormat, label: &str) -> Result<Value> {
if spec == "-" {
let contents = read_from_source(&InputSource::Stdin)?;
return parse_contents(&contents, format, label);
}
let path = PathBuf::from(spec);
match read_from_source(&InputSource::File(path.clone())) {
Ok(contents) => parse_contents(&contents, format, label),
Err(err) => {
if is_not_found(&err) {
let inline_label = format!("inline {label}");
return parse_contents(spec, format, &inline_label);
}
Err(err.wrap_err(format!("failed to load {label} from {}", path.display())))
}
}
}
fn read_from_source(source: &InputSource) -> Result<String> {
match source {
InputSource::Stdin => {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.wrap_err("failed to read from stdin")?;
Ok(buffer)
}
InputSource::File(path) => fs::read_to_string(path)
.wrap_err_with(|| format!("failed to read file {}", path.display())),
}
}
fn is_not_found(err: &Report) -> bool {
err.downcast_ref::<io::Error>()
.map_or(false, |io_err| io_err.kind() == io::ErrorKind::NotFound)
}
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(Report::msg(format!(
"failed to parse {label}: tried {} (first error: {primary})",
format_list()
)))
}
}
}
fn looks_like_json_schema(value: &Value) -> bool {
let obj = match value.as_object() {
Some(map) => map,
None => return false,
};
if obj
.get("properties")
.and_then(Value::as_object)
.map(|props| props.len())
.unwrap_or(0)
== 0
{
return false;
}
if obj.contains_key("$schema") {
return true;
}
if matches!(obj.get("type"), Some(Value::String(t)) if t == "object") {
return true;
}
if let Some(props) = obj.get("properties").and_then(Value::as_object) {
let mut scored = 0usize;
for value in props.values() {
if value.get("type").is_some() {
scored += 1;
}
if value.get("properties").is_some() {
scored += 1;
}
if value.get("enum").is_some() {
scored += 1;
}
}
return scored >= 2;
}
false
}
fn format_list() -> &'static str {
#[cfg(all(feature = "yaml", feature = "toml"))]
{
"JSON/YAML/TOML"
}
#[cfg(all(feature = "yaml", not(feature = "toml")))]
{
"JSON/YAML"
}
#[cfg(all(not(feature = "yaml"), feature = "toml"))]
{
"JSON/TOML"
}
#[cfg(all(not(feature = "yaml"), not(feature = "toml")))]
{
"JSON"
}
}
fn build_output_options(
args: &CommonArgs,
config_hint: Option<DocumentFormat>,
schema_hint: Option<DocumentFormat>,
diagnostics: &mut DiagnosticCollector,
) -> (Option<OutputOptions>, Vec<PathBuf>) {
let mut destinations = Vec::new();
let explicit_outputs = !args.outputs.is_empty();
for raw in &args.outputs {
if raw.trim().is_empty() {
diagnostics.push_output("output destination cannot be empty");
continue;
}
if raw == "-" {
destinations.push(OutputDestination::Stdout);
} else {
destinations.push(OutputDestination::file(raw));
}
}
if destinations.is_empty() && !explicit_outputs {
if args.no_temp_file {
return (None, Vec::new());
}
let fallback = args
.temp_file
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_TEMP_FILE));
destinations.push(OutputDestination::file(fallback.clone()));
}
if destinations.is_empty() {
return (None, Vec::new());
}
let file_paths: Vec<PathBuf> = destinations
.iter()
.filter_map(|dest| match dest {
OutputDestination::File(path) => Some(path.clone()),
OutputDestination::Stdout => None,
})
.collect();
let start = diagnostics.len();
let format = if file_paths.is_empty() {
determine_stdout_format(config_hint, schema_hint)
} else {
infer_format_from_files(&file_paths, diagnostics).unwrap_or_else(DocumentFormat::default)
};
if diagnostics.len() > start {
return (None, file_paths);
}
(
Some(OutputOptions {
format,
pretty: !args.no_pretty,
destinations,
}),
file_paths,
)
}
fn determine_stdout_format(
config_hint: Option<DocumentFormat>,
schema_hint: Option<DocumentFormat>,
) -> DocumentFormat {
config_hint
.or(schema_hint)
.unwrap_or_else(DocumentFormat::default)
}
fn infer_format_from_files(
file_paths: &[PathBuf],
diagnostics: &mut DiagnosticCollector,
) -> Option<DocumentFormat> {
let mut detected: Option<DocumentFormat> = None;
for path in file_paths {
match probe_format_from_extension(path) {
ExtensionFormat::Known(format) => {
if let Some(existing) = detected {
if existing != format {
diagnostics.push_output(format!(
"output file {} uses {format} but other destinations use {existing}; align extensions",
path.display()
));
}
} else {
detected = Some(format);
}
}
ExtensionFormat::UnsupportedFeature {
format_name,
feature_flag,
} => diagnostics.push_output(format!(
"output file {} requires {format_name} support, but this build was compiled without the '{feature_flag}' feature",
path.display()
)),
ExtensionFormat::Unknown => diagnostics.push_output(format!(
"cannot infer format from output file {}; use .json/.yaml/.toml",
path.display()
)),
}
}
detected
}
fn probe_format_from_extension(path: &Path) -> ExtensionFormat {
let Some(ext) = path.extension() else {
return ExtensionFormat::Unknown;
};
let normalized = ext.to_string_lossy().to_ascii_lowercase();
match normalized.as_str() {
"json" => ExtensionFormat::Known(DocumentFormat::Json),
#[cfg(feature = "yaml")]
"yaml" | "yml" => ExtensionFormat::Known(DocumentFormat::Yaml),
#[cfg(not(feature = "yaml"))]
"yaml" | "yml" => ExtensionFormat::UnsupportedFeature {
format_name: "yaml",
feature_flag: "yaml",
},
#[cfg(feature = "toml")]
"toml" => ExtensionFormat::Known(DocumentFormat::Toml),
#[cfg(not(feature = "toml"))]
"toml" => ExtensionFormat::UnsupportedFeature {
format_name: "toml",
feature_flag: "toml",
},
_ => ExtensionFormat::Unknown,
}
}
#[derive(Debug)]
enum ExtensionFormat {
Known(DocumentFormat),
#[allow(dead_code)]
UnsupportedFeature {
format_name: &'static str,
feature_flag: &'static str,
},
Unknown,
}
fn ensure_output_paths_available(
paths: &[PathBuf],
force: bool,
diagnostics: &mut DiagnosticCollector,
) {
if force {
return;
}
for path in paths {
if path.exists() {
diagnostics.push_output(format!(
"file {} already exists (pass --force to overwrite)",
path.display()
));
}
}
}
#[derive(Debug, Default)]
struct DiagnosticCollector {
messages: Vec<String>,
}
impl DiagnosticCollector {
fn push_input(&mut self, label: &str, message: impl Into<String>) {
self.messages.push(format!("{label}: {}", message.into()));
}
fn push_output(&mut self, message: impl Into<String>) {
self.messages.push(format!("output: {}", message.into()));
}
fn len(&self) -> usize {
self.messages.len()
}
fn into_result(self) -> Result<()> {
if self.messages.is_empty() {
return Ok(());
}
let mut body = String::from("encountered input/output issues:\n");
for (idx, msg) in self.messages.iter().enumerate() {
let _ = writeln!(body, " {}. {}", idx + 1, msg);
}
Err(eyre!(body))
}
}