tjson-rs 0.6.1

Text JSON (TJSON) - a readability optimized, round trip compatible alternative to JSON
Documentation
use std::path::Path;
use tjson::{TjsonConfig, RenderOptions, Value};

fn tests_dir() -> std::path::PathBuf {
    let pathbuf = if let Ok(p) = std::env::var("TJSON_TESTS_DIR") {
        std::path::PathBuf::from(p)
    } else {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures")
    };
    let tests_missing_message = "You are trying to test without the tjson-tests subrepository.  Did you follow the instructions in CONTRIBUTING.md first?";
    _ = pathbuf.read_dir().unwrap_or_else(|e| panic!("Cannot read tests directory {pathbuf:?}: {e}\n{tests_missing_message}"))
        .next().unwrap_or_else(|| panic!("Cannot find anything in tests directory {pathbuf:?}.\n{tests_missing_message}"));
    pathbuf
}

#[test]
fn parse_valid() {
    let base = tests_dir().join("parse/valid");
    let expected_dir = base.join("expected");
    let mut failures: Vec<String> = Vec::new();
    let mut total = 0usize;

    let entries: Vec<_> = std::fs::read_dir(&base)
        .expect("cannot read parse/valid dir")
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.path()
                .extension()
                .map(|x| x == "tjson")
                .unwrap_or(false)
        })
        .collect();

    if entries.is_empty() {
        panic!("No .tjson files found in {:?}", base);
    }

    for entry in entries {
        total += 1;
        let tjson_path = entry.path();
        let stem = tjson_path.file_stem().unwrap().to_string_lossy().into_owned();
        let json_path = expected_dir.join(format!("{}.json", stem));

        let tjson_src = match std::fs::read_to_string(&tjson_path) {
            Ok(s) => s,
            Err(e) => {
                failures.push(format!("{}: could not read: {}", stem, e));
                continue;
            }
        };

        let parsed: Value = match tjson_src.parse() {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: parse error: {}", stem, e));
                continue;
            }
        };

        let expected_json_src = match std::fs::read_to_string(&json_path) {
            Ok(s) => s,
            Err(e) => {
                failures.push(format!("{}: missing expected JSON at {:?}: {}", stem, json_path, e));
                continue;
            }
        };

        let expected_json: serde_json::Value = match serde_json::from_str(&expected_json_src) {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: could not parse expected JSON: {}", stem, e));
                continue;
            }
        };

        let actual_json: serde_json::Value = serde_json::from_str(&parsed.to_json()).unwrap();

        if actual_json != expected_json {
            failures.push(format!(
                "{}: mismatch\n  expected: {}\n  actual:   {}",
                stem,
                serde_json::to_string(&expected_json).unwrap(),
                serde_json::to_string(&actual_json).unwrap()
            ));
        }
    }

    if !failures.is_empty() {
        panic!("\n\nFAILED: {}/{} parse_valid fixture(s):\n\n{}\n", failures.len(), total, failures.join("\n\n"));
    }
}

#[test]
fn parse_invalid() {
    let base = tests_dir().join("parse/invalid");
    let mut failures: Vec<String> = Vec::new();
    let mut total = 0usize;

    let entries: Vec<_> = std::fs::read_dir(&base)
        .expect("cannot read parse/invalid dir")
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.path()
                .extension()
                .map(|x| x == "tjson")
                .unwrap_or(false)
        })
        .collect();

    if entries.is_empty() {
        panic!("No .tjson files found in {:?}", base);
    }

    for entry in entries {
        total += 1;
        let tjson_path = entry.path();
        let stem = tjson_path.file_stem().unwrap().to_string_lossy().into_owned();

        let tjson_src = match std::fs::read_to_string(&tjson_path) {
            Ok(s) => s,
            Err(e) => {
                failures.push(format!("{}: could not read: {}", stem, e));
                continue;
            }
        };

        match tjson_src.parse::<Value>() {
            Ok(v) => {
                failures.push(format!(
                    "{}: expected parse error but got: {:?}",
                    stem, v
                ));
            }
            Err(_) => {
                // expected
            }
        }
    }

    if !failures.is_empty() {
        panic!("\n\nFAILED: {}/{} parse_invalid fixture(s):\n\n{}\n", failures.len(), total, failures.join("\n\n"));
    }
}

#[test]
fn roundtrip() {
    let base = tests_dir().join("roundtrip");
    let mut failures: Vec<String> = Vec::new();
    let mut total = 0usize;

    let entries: Vec<_> = std::fs::read_dir(&base)
        .expect("cannot read roundtrip dir")
        .filter_map(|e| e.ok())
        .filter(|e| {
            let p = e.path();
            // skip known-bugs subdirectory
            if p.is_dir() {
                return false;
            }
            p.extension().map(|x| x == "tjson").unwrap_or(false)
        })
        .collect();

    if entries.is_empty() {
        panic!("No .tjson files found in {:?}", base);
    }

    for entry in entries {
        total += 1;
        let tjson_path = entry.path();
        let stem = tjson_path.file_stem().unwrap().to_string_lossy().into_owned();

        let tjson_src = match std::fs::read_to_string(&tjson_path) {
            Ok(s) => s,
            Err(e) => {
                failures.push(format!("{}: could not read: {}", stem, e));
                continue;
            }
        };

        // parse
        let parsed: Value = match tjson_src.parse() {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: parse error: {}", stem, e));
                continue;
            }
        };

        let original_json: serde_json::Value = serde_json::from_str(&parsed.to_json()).unwrap();

        // render
        let rendered = parsed.to_tjson_with(RenderOptions::default());

        // reparse
        let reparsed: Value = match rendered.parse() {
            Ok(v) => v,
            Err(e) => {
                failures.push(format!("{}: reparse error: {}", stem, e));
                continue;
            }
        };

        let reparsed_json: serde_json::Value = serde_json::from_str(&reparsed.to_json()).unwrap();

        if original_json != reparsed_json {
            failures.push(format!(
                "{}: roundtrip mismatch\n  original: {}\n  after roundtrip: {}",
                stem,
                serde_json::to_string(&original_json).unwrap(),
                serde_json::to_string(&reparsed_json).unwrap()
            ));
        }
    }

    if !failures.is_empty() {
        panic!("\n\nFAILED: {}/{} roundtrip fixture(s):\n\n{}\n", failures.len(), total, failures.join("\n\n"));
    }
}

#[test]
fn render() {
    let render_base = tests_dir().join("render");
    let mut failures: Vec<String> = Vec::new();
    let mut total = 0usize;

    let subdirs: Vec<_> = std::fs::read_dir(&render_base)
        .expect("cannot read render dir")
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
        .collect();

    if subdirs.is_empty() {
        panic!("No subdirs found in {:?}", render_base);
    }

    for subdir_entry in subdirs {
        let subdir = subdir_entry.path();
        let subdir_name = subdir.file_name().unwrap().to_string_lossy().into_owned();

        let config_path = subdir.join("config.json");
        let config_src = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(e) => {
                failures.push(format!("{}: could not read config.json: {}", subdir_name, e));
                continue;
            }
        };

        let config: TjsonConfig = match serde_json::from_str(&config_src) {
            Ok(o) => o,
            Err(e) => {
                failures.push(format!("{}: could not parse config.json: {}", subdir_name, e));
                continue;
            }
        };
        let options: RenderOptions = config.into();

        let json_entries: Vec<_> = std::fs::read_dir(&subdir)
            .expect("cannot read subdir")
            .filter_map(|e| e.ok())
            .filter(|e| {
                let p = e.path();
                p.extension().map(|x| x == "json").unwrap_or(false)
                    && p.file_name().map(|n| n != "config.json").unwrap_or(false)
            })
            .collect();

        for json_entry in json_entries {
            total += 1;
            let json_path = json_entry.path();
            let stem = json_path.file_stem().unwrap().to_string_lossy().into_owned();
            let tjson_path = subdir.join(format!("{}.tjson", stem));
            let test_name = format!("{}/{}", subdir_name, stem);

            let json_src = match std::fs::read_to_string(&json_path) {
                Ok(s) => s,
                Err(e) => {
                    failures.push(format!("{}: could not read JSON input: {}", test_name, e));
                    continue;
                }
            };

            let json_val: serde_json::Value = match serde_json::from_str(&json_src) {
                Ok(v) => v,
                Err(e) => {
                    failures.push(format!("{}: could not parse JSON input: {}", test_name, e));
                    continue;
                }
            };

            let tjson_val = Value::from(json_val);

            let rendered = tjson_val.to_tjson_with(options.clone());

            let expected_raw = match std::fs::read_to_string(&tjson_path) {
                Ok(s) => s,
                Err(e) => {
                    panic!(
                        "{}: missing expected .tjson file at {:?}: {}",
                        test_name, tjson_path, e
                    );
                }
            };

            // Strip single trailing newline from expected file
            let expected = expected_raw.strip_suffix('\n').unwrap_or(&expected_raw);

            if rendered != expected {
                failures.push(format!(
                    "{}: render mismatch\n  expected: {:?}\n  actual:   {:?}",
                    test_name, expected, rendered
                ));
            }
        }
    }

    if !failures.is_empty() {
        panic!("\n\nFAILED: {}/{} render fixture(s):\n\n{}\n", failures.len(), total, failures.join("\n\n"));
    }
}