use serde_json::Value;
use std::env;
use std::fs;
use std::io::{self, Read};
use std::process;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let options = match parse_args(&args) {
Ok(options) => options,
Err(message) => exit_with_error(&message),
};
if let Err(message) = run(options) {
exit_with_error(&message);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Json,
Ssof,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Options {
from: Format,
to: Format,
apply: bool,
input: Option<String>,
input_file: Option<String>,
base: Option<String>,
base_file: Option<String>,
pretty: bool,
}
fn parse_args(args: &[String]) -> std::result::Result<Options, String> {
let mut from = None;
let mut to = None;
let mut apply = false;
let mut input = None;
let mut input_file = None;
let mut base = None;
let mut base_file = None;
let mut pretty = false;
let mut index = 0usize;
while index < args.len() {
match args[index].as_str() {
"--from" => {
index += 1;
from = Some(parse_format(
args
.get(index)
.ok_or_else(|| "missing value for --from".to_owned())?,
)?);
}
"--to" => {
index += 1;
to = Some(parse_format(
args
.get(index)
.ok_or_else(|| "missing value for --to".to_owned())?,
)?);
}
"--apply" => apply = true,
"--input" => {
index += 1;
input = Some(
args
.get(index)
.ok_or_else(|| "missing value for --input".to_owned())?
.clone(),
);
}
"--input-file" => {
index += 1;
input_file = Some(
args
.get(index)
.ok_or_else(|| "missing value for --input-file".to_owned())?
.clone(),
);
}
"--base" => {
index += 1;
base = Some(
args
.get(index)
.ok_or_else(|| "missing value for --base".to_owned())?
.clone(),
);
}
"--base-file" => {
index += 1;
base_file = Some(
args
.get(index)
.ok_or_else(|| "missing value for --base-file".to_owned())?
.clone(),
);
}
"--pretty" => pretty = true,
"--help" | "-h" => return Err(usage()),
flag => return Err(format!("unknown flag: {flag}\n\n{}", usage())),
}
index += 1;
}
let from = from.ok_or_else(|| format!("missing required --from\n\n{}", usage()))?;
let to = to.ok_or_else(|| format!("missing required --to\n\n{}", usage()))?;
if apply {
if from != Format::Ssof || to != Format::Json {
return Err("--apply requires --from ssof --to json".to_owned());
}
if base.is_none() && base_file.is_none() {
return Err("--apply requires --base or --base-file".to_owned());
}
}
Ok(Options {
from,
to,
apply,
input,
input_file,
base,
base_file,
pretty,
})
}
fn parse_format(value: &str) -> std::result::Result<Format, String> {
match value {
"json" => Ok(Format::Json),
"ssof" => Ok(Format::Ssof),
_ => Err(format!("unsupported format: {value}")),
}
}
fn usage() -> String {
"Usage: ssof-cli --from <json|ssof> --to <json|ssof> [--apply] [--input <text> | --input-file <path>] [--base <json> | --base-file <path>] [--pretty]".to_owned()
}
fn run(options: Options) -> std::result::Result<(), String> {
if options.apply
&& options.input_file.as_deref() == Some("-")
&& options.base_file.as_deref() == Some("-")
{
return Err("cannot read both --input-file - and --base-file - from stdin".to_owned());
}
let input = read_input(options.input.as_deref(), options.input_file.as_deref())?;
if options.apply {
let mut base = read_base_json(options.base.as_deref(), options.base_file.as_deref())?;
ssof::apply_str(&mut base, &input).map_err(|error| error.to_string())?;
write_json(&base, options.pretty)?;
return Ok(());
}
match (options.from, options.to) {
(Format::Ssof, Format::Json) => {
let value = ssof::parse_str(&input).map_err(|error| error.to_string())?;
write_json(&value, options.pretty)
}
(Format::Json, Format::Ssof) => {
let value: Value = serde_json::from_str(&input).map_err(|error| error.to_string())?;
let encoded = ssof::to_string(&value).map_err(|error| error.to_string())?;
println!("{encoded}");
Ok(())
}
(Format::Json, Format::Json) => {
let value: Value = serde_json::from_str(&input).map_err(|error| error.to_string())?;
write_json(&value, options.pretty)
}
(Format::Ssof, Format::Ssof) => {
let value = ssof::parse_str(&input).map_err(|error| error.to_string())?;
let encoded = ssof::to_string(&value).map_err(|error| error.to_string())?;
println!("{encoded}");
Ok(())
}
}
}
fn read_input(
input: Option<&str>,
input_file: Option<&str>,
) -> std::result::Result<String, String> {
if let Some(input) = input {
return Ok(input.to_owned());
}
if let Some(path) = input_file {
return read_text_source(path);
}
read_stdin()
}
fn read_text_source(path: &str) -> std::result::Result<String, String> {
if path == "-" {
return read_stdin();
}
fs::read_to_string(path).map_err(|error| error.to_string())
}
fn read_stdin() -> std::result::Result<String, String> {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| error.to_string())?;
Ok(buffer)
}
fn read_base_json(
base: Option<&str>,
base_file: Option<&str>,
) -> std::result::Result<Value, String> {
let source = if let Some(base) = base {
base.to_owned()
} else if let Some(path) = base_file {
read_text_source(path)?
} else {
return Err("missing base json".to_owned());
};
serde_json::from_str(&source).map_err(|error| error.to_string())
}
fn write_json(value: &Value, pretty: bool) -> std::result::Result<(), String> {
let output = if pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
}
.map_err(|error| error.to_string())?;
println!("{output}");
Ok(())
}
fn exit_with_error(message: &str) -> ! {
eprintln!("{message}");
process::exit(1)
}
#[cfg(test)]
mod tests {
use super::{parse_args, read_base_json, read_input, run, Format, Options};
#[test]
fn parses_apply_mode() {
let args = vec![
"--from".to_owned(),
"ssof".to_owned(),
"--to".to_owned(),
"json".to_owned(),
"--apply".to_owned(),
"--base".to_owned(),
"{}".to_owned(),
];
assert_eq!(
parse_args(&args).unwrap(),
Options {
from: Format::Ssof,
to: Format::Json,
apply: true,
input: None,
input_file: None,
base: Some("{}".to_owned()),
base_file: None,
pretty: false,
}
);
}
#[test]
fn rejects_invalid_apply_pairing() {
let args = vec![
"--from".to_owned(),
"json".to_owned(),
"--to".to_owned(),
"ssof".to_owned(),
"--apply".to_owned(),
"--base".to_owned(),
"{}".to_owned(),
];
assert!(parse_args(&args).is_err());
}
#[test]
fn reads_inline_input_before_file() {
assert_eq!(read_input(Some("hello"), Some("-")).unwrap(), "hello");
}
#[test]
fn rejects_dual_stdin_sources_in_apply_mode() {
let error = run(Options {
from: Format::Ssof,
to: Format::Json,
apply: true,
input: None,
input_file: Some("-".to_owned()),
base: None,
base_file: Some("-".to_owned()),
pretty: false,
})
.unwrap_err();
assert_eq!(
error,
"cannot read both --input-file - and --base-file - from stdin"
);
}
#[test]
fn parses_base_json_from_inline_text() {
let value = read_base_json(Some("{\"ok\":true}"), None).unwrap();
assert_eq!(value["ok"], true);
}
}