ssof-cli 0.2.1

CLI for converting and applying Shell Safe Options Format data
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 rejects_missing_required_format_flags() {
    let args = vec!["--to".to_owned(), "json".to_owned()];
    assert!(parse_args(&args).is_err());

    let args = vec!["--from".to_owned(), "ssof".to_owned()];
    assert!(parse_args(&args).is_err());
  }

  #[test]
  fn rejects_missing_flag_values_and_unknown_inputs() {
    let args = vec!["--from".to_owned()];
    assert!(parse_args(&args).is_err());

    let args = vec![
      "--from".to_owned(),
      "yaml".to_owned(),
      "--to".to_owned(),
      "json".to_owned(),
    ];
    assert!(parse_args(&args).is_err());

    let args = vec!["--wat".to_owned()];
    assert!(parse_args(&args).is_err());
  }

  #[test]
  fn help_returns_usage_text() {
    let args = vec!["--help".to_owned()];
    let error = parse_args(&args).unwrap_err();
    assert!(error.starts_with("Usage: ssof-cli"));
  }

  #[test]
  fn apply_requires_base_input() {
    let args = vec![
      "--from".to_owned(),
      "ssof".to_owned(),
      "--to".to_owned(),
      "json".to_owned(),
      "--apply".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);
  }
}