stick-and-string 1.1.2

SAS 1.1 (Stick And String) — human-readable data serialization format. Parser, validator, and bidirectional JSON converter.
Documentation
use std::{env, fs, path::Path, process};
use sas::{parse, to_json, from_json, FromJsonOptions, ToJsonOptions};

const USAGE: &str = "\
SAS 1.1 Tools — Parser & Converter

Usage:
  sas <command> <file> [options]

Commands:
  parse     <file.sas>        Parse SAS and output JSON to stdout
  to-json   <file.sas>        Alias for parse
  to-sas    <file.json>       Convert JSON to SAS and output to stdout
  validate  <file.sas>        Validate SAS; exit 0 if valid, 1 if errors
  roundtrip <file>            SAS→JSON→SAS or JSON→SAS→JSON round-trip check

Options:
  --compact                   Compact JSON output (no indentation)
  --no-version                Omit __sas_version__ header in SAS output
  --output <file>             Write output to file instead of stdout
";

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 || args[1] == "--help" || args[1] == "-h" {
        print!("{}", USAGE);
        return;
    }

    let cmd = &args[1];
    if args.len() < 3 {
        eprintln!("Error: missing input file for command {:?}", cmd);
        process::exit(1);
    }

    let input_file = &args[2];
    let flags = parse_flags(&args[3..]);

    let src = fs::read_to_string(input_file).unwrap_or_else(|e| {
        eprintln!("Error: cannot read file {}: {}", input_file, e);
        process::exit(1);
    });

    match cmd.as_str() {
        "parse" | "to-json" => {
            let mut opts = ToJsonOptions::default();
            if flags.iter().any(|f| f == "compact") {
                opts.indent = String::new();
            }
            match to_json(&src, opts) {
                Ok(result) => write_output(&result, flags.iter().position(|f| f == "output").and_then(|i| flags.get(i + 1))),
                Err(e)     => { eprintln!("Error: {}", e); process::exit(1); }
            }
        }

        "to-sas" => {
            let mut opts = FromJsonOptions::default();
            if flags.iter().any(|f| f == "no-version") {
                opts.version_header = false;
            }
            match from_json(&src, opts) {
                Ok(result) => write_output(&result, flags.iter().position(|f| f == "output").and_then(|i| flags.get(i + 1))),
                Err(e)     => { eprintln!("Error: {}", e); process::exit(1); }
            }
        }

        "validate" => {
            match parse(&src) {
                Ok(_)  => println!("✓  Valid SAS 1.1 document: {}", input_file),
                Err(e) => { eprintln!("✗  Parse error: {}", e); process::exit(1); }
            }
        }

        "roundtrip" => {
            let ext = Path::new(input_file).extension()
                .and_then(|e| e.to_str())
                .unwrap_or("")
                .to_lowercase();
            match ext.as_str() {
                "sas"  => roundtrip_sas(&src, input_file),
                "json" => roundtrip_json(&src, input_file),
                _      => { eprintln!("Error: roundtrip requires a .sas or .json file"); process::exit(1); }
            }
        }

        other => {
            eprintln!("Error: unknown command {:?}\n\n{}", other, USAGE);
            process::exit(1);
        }
    }
}

fn roundtrip_sas(src: &str, label: &str) {
    println!("Roundtrip: {}  (SAS → JSON → SAS)\n", label);

    let json_str = match to_json(src, ToJsonOptions::default()) {
        Ok(s) => { println!("  ✓ Parse SAS"); s }
        Err(e) => { eprintln!("  ✗ Parse SAS failed: {}", e); process::exit(1); }
    };

    let opts = FromJsonOptions { version_header: false, ..Default::default() };
    let sas2 = match from_json(&json_str, opts) {
        Ok(s) => s,
        Err(e) => { eprintln!("  ✗ Re-encode failed: {}", e); process::exit(1); }
    };

    match parse(&sas2) {
        Ok(_)  => println!("  ✓ Re-encode to SAS and re-parse"),
        Err(e) => { eprintln!("  ✗ Re-parse failed: {}\nSAS:\n{}", e, sas2); process::exit(1); }
    }

    println!("  ✓ Data preserved exactly\n\n✓  Roundtrip OK");
}

fn roundtrip_json(src: &str, label: &str) {
    println!("Roundtrip: {}  (JSON → SAS → JSON)\n", label);

    let opts = FromJsonOptions { version_header: false, ..Default::default() };
    let sas_str = match from_json(src, opts) {
        Ok(s) => { println!("  ✓ Parse JSON + Encode to SAS"); s }
        Err(e) => { eprintln!("  ✗ Encode to SAS failed: {}", e); process::exit(1); }
    };

    match to_json(&sas_str, ToJsonOptions::default()) {
        Ok(_)  => println!("  ✓ Re-parse SAS\n  ✓ Data preserved exactly\n\n✓  Roundtrip OK"),
        Err(e) => { eprintln!("  ✗ Re-parse failed: {}", e); process::exit(1); }
    }
}

fn write_output(text: &str, output_file: Option<&String>) {
    if let Some(path) = output_file {
        fs::write(path, text).unwrap_or_else(|e| {
            eprintln!("Error: cannot write file {}: {}", path, e);
            process::exit(1);
        });
        println!("Written to {}", path);
    } else {
        print!("{}", text);
        if !text.ends_with('\n') { println!(); }
    }
}

fn parse_flags(args: &[String]) -> Vec<String> {
    let mut flags = Vec::new();
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--compact"    => flags.push("compact".into()),
            "--no-version" => flags.push("no-version".into()),
            "--output"     => {
                flags.push("output".into());
                if i + 1 < args.len() {
                    i += 1;
                    flags.push(args[i].clone());
                }
            }
            _ => {}
        }
        i += 1;
    }
    flags
}