nested-text 0.1.0

A fully spec-compliant NestedText v3.8 parser and serializer
Documentation
use base64::Engine;
use serde_json;
use std::collections::HashMap;

/// Convert our nested_text::Value to serde_json::Value for comparison with test suite expectations.
fn value_to_json(v: &nested_text::Value) -> serde_json::Value {
    match v {
        nested_text::Value::String(s) => serde_json::Value::String(s.clone()),
        nested_text::Value::List(items) => {
            serde_json::Value::Array(items.iter().map(value_to_json).collect())
        }
        nested_text::Value::Dict(pairs) => {
            let map: serde_json::Map<String, serde_json::Value> = pairs
                .iter()
                .map(|(k, v)| (k.clone(), value_to_json(v)))
                .collect();
            serde_json::Value::Object(map)
        }
    }
}

#[derive(serde::Deserialize, Debug)]
struct TestSuite {
    load_tests: HashMap<String, TestCase>,
}

#[derive(serde::Deserialize, Debug)]
struct TestCase {
    load_in: Option<String>,
    load_out: Option<serde_json::Value>,
    load_err: Option<serde_json::Value>,
    #[allow(dead_code)]
    encoding: Option<String>,
    #[allow(dead_code)]
    types: Option<serde_json::Value>,
}

#[test]
fn run_official_load_tests() {
    let data =
        std::fs::read_to_string("tests/nestedtext_tests/tests.json").expect("tests.json not found — did you init the submodule?");
    let suite: TestSuite = serde_json::from_str(&data).expect("failed to parse tests.json");

    let mut passed = 0;
    let mut failed = 0;
    let mut skipped = 0;
    let mut failures: Vec<String> = Vec::new();

    let mut test_names: Vec<&String> = suite.load_tests.keys().collect();
    test_names.sort();

    for name in &test_names {
        let case = &suite.load_tests[*name];

        // Skip tests without load_in
        let load_in_b64 = match &case.load_in {
            Some(s) => s,
            None => {
                skipped += 1;
                continue;
            }
        };

        // Decode base64 input
        let input_bytes = match base64::engine::general_purpose::STANDARD.decode(load_in_b64) {
            Ok(b) => b,
            Err(e) => {
                failures.push(format!("{}: base64 decode error: {}", name, e));
                failed += 1;
                continue;
            }
        };

        let input = match String::from_utf8(input_bytes) {
            Ok(s) => s,
            Err(_) => {
                // Non-UTF8 input — skip for now (bytes_in tests)
                skipped += 1;
                continue;
            }
        };

        // Determine if this is a success or error test
        let expects_error = case
            .load_err
            .as_ref()
            .map_or(false, |e| !e.is_object() || !e.as_object().unwrap().is_empty());

        if expects_error {
            // We expect parsing to fail with matching error details
            let expected_err = case.load_err.as_ref().unwrap().as_object().unwrap();
            match nested_text::loads(&input, nested_text::Top::Any) {
                Err(e) => {
                    let mut detail_failures = Vec::new();

                    // Check error message
                    if let Some(serde_json::Value::String(expected_msg)) = expected_err.get("message") {
                        if e.message != *expected_msg {
                            detail_failures.push(format!(
                                "message: expected {:?}, got {:?}",
                                expected_msg, e.message
                            ));
                        }
                    }

                    // Check line number (0-based)
                    if let Some(expected_lineno) = expected_err.get("lineno") {
                        if !expected_lineno.is_null() {
                            let expected_ln = expected_lineno.as_u64().unwrap() as usize;
                            match e.lineno {
                                Some(ln) if ln == expected_ln => {}
                                Some(ln) => {
                                    detail_failures.push(format!(
                                        "lineno: expected {}, got {}",
                                        expected_ln, ln
                                    ));
                                }
                                None => {
                                    detail_failures.push(format!(
                                        "lineno: expected {}, got None",
                                        expected_ln
                                    ));
                                }
                            }
                        }
                    }

                    // Check column number (0-based)
                    if let Some(expected_colno) = expected_err.get("colno") {
                        if !expected_colno.is_null() {
                            let expected_cn = expected_colno.as_u64().unwrap() as usize;
                            match e.colno {
                                Some(cn) if cn == expected_cn => {}
                                Some(cn) => {
                                    detail_failures.push(format!(
                                        "colno: expected {}, got {}",
                                        expected_cn, cn
                                    ));
                                }
                                None => {
                                    detail_failures.push(format!(
                                        "colno: expected {}, got None",
                                        expected_cn
                                    ));
                                }
                            }
                        }
                    }

                    // Check source line
                    if let Some(serde_json::Value::String(expected_line)) = expected_err.get("line") {
                        match &e.line {
                            Some(line) if line == expected_line => {}
                            Some(line) => {
                                detail_failures.push(format!(
                                    "line: expected {:?}, got {:?}",
                                    expected_line, line
                                ));
                            }
                            None => {
                                detail_failures.push(format!(
                                    "line: expected {:?}, got None",
                                    expected_line
                                ));
                            }
                        }
                    }

                    if detail_failures.is_empty() {
                        passed += 1;
                    } else {
                        failures.push(format!(
                            "{}: error detail mismatch\n  {}",
                            name,
                            detail_failures.join("\n  ")
                        ));
                        failed += 1;
                    }
                }
                Ok(v) => {
                    failures.push(format!(
                        "{}: expected error but got success: {:?}",
                        name, v
                    ));
                    failed += 1;
                }
            }
        } else {
            // We expect parsing to succeed
            let expected = case.load_out.as_ref();
            match nested_text::loads(&input, nested_text::Top::Any) {
                Ok(result) => {
                    let json_result = match &result {
                        Some(v) => value_to_json(v),
                        None => serde_json::Value::Null,
                    };
                    let expected_json = expected.cloned().unwrap_or(serde_json::Value::Null);
                    if json_result == expected_json {
                        passed += 1;
                    } else {
                        failures.push(format!(
                            "{}: output mismatch\n  expected: {}\n  got:      {}",
                            name,
                            serde_json::to_string(&expected_json).unwrap(),
                            serde_json::to_string(&json_result).unwrap(),
                        ));
                        failed += 1;
                    }
                }
                Err(e) => {
                    failures.push(format!("{}: unexpected error: {}", name, e));
                    failed += 1;
                }
            }
        }
    }

    // Print summary
    eprintln!("\n=== Official Load Test Results ===");
    eprintln!("Passed:  {}", passed);
    eprintln!("Failed:  {}", failed);
    eprintln!("Skipped: {}", skipped);
    eprintln!("Total:   {}", passed + failed + skipped);

    if !failures.is_empty() {
        eprintln!("\n=== Failures ===");
        for f in &failures {
            eprintln!("{}\n", f);
        }
    }

    assert!(
        failed == 0,
        "{} test(s) failed out of {} (see above for details)",
        failed,
        passed + failed
    );
}

/// Round-trip dump tests: for every successful load test, dump the result
/// back to NestedText, then load it again and verify we get the same value.
/// This is the official recommended approach for testing the dumper (see README).
#[test]
fn run_roundtrip_dump_tests() {
    let data =
        std::fs::read_to_string("tests/nestedtext_tests/tests.json").expect("tests.json not found");
    let suite: TestSuite = serde_json::from_str(&data).expect("failed to parse tests.json");

    let mut passed = 0;
    let mut failed = 0;
    let mut skipped = 0;
    let mut failures: Vec<String> = Vec::new();

    let mut test_names: Vec<&String> = suite.load_tests.keys().collect();
    test_names.sort();

    for name in &test_names {
        let case = &suite.load_tests[*name];

        let load_in_b64 = match &case.load_in {
            Some(s) => s,
            None => {
                skipped += 1;
                continue;
            }
        };

        let input_bytes = match base64::engine::general_purpose::STANDARD.decode(load_in_b64) {
            Ok(b) => b,
            Err(_) => {
                skipped += 1;
                continue;
            }
        };

        let input = match String::from_utf8(input_bytes) {
            Ok(s) => s,
            Err(_) => {
                skipped += 1;
                continue;
            }
        };

        // Only test successful loads
        let expects_error = case
            .load_err
            .as_ref()
            .map_or(false, |e| !e.is_object() || !e.as_object().unwrap().is_empty());

        if expects_error {
            skipped += 1;
            continue;
        }

        // Load the original
        let original = match nested_text::loads(&input, nested_text::Top::Any) {
            Ok(Some(v)) => v,
            Ok(None) => {
                // Empty document — dump should produce empty or parseable output
                let dumped = nested_text::dumps(&nested_text::Value::String(String::new()), &nested_text::DumpOptions::default());
                match nested_text::loads(&dumped, nested_text::Top::Any) {
                    Ok(_) => {
                        passed += 1;
                        continue;
                    }
                    Err(e) => {
                        failures.push(format!("{}: empty doc roundtrip failed: {}", name, e));
                        failed += 1;
                        continue;
                    }
                }
            }
            Err(_) => {
                skipped += 1;
                continue;
            }
        };

        // Dump to NestedText
        let dumped = nested_text::dumps(&original, &nested_text::DumpOptions::default());

        // Load back
        match nested_text::loads(&dumped, nested_text::Top::Any) {
            Ok(Some(roundtripped)) => {
                if original == roundtripped {
                    passed += 1;
                } else {
                    failures.push(format!(
                        "{}: roundtrip mismatch\n  original:    {:?}\n  roundtripped: {:?}\n  dumped NT:\n{}",
                        name, original, roundtripped, dumped
                    ));
                    failed += 1;
                }
            }
            Ok(None) => {
                failures.push(format!(
                    "{}: roundtrip produced empty document\n  dumped NT:\n{}",
                    name, dumped
                ));
                failed += 1;
            }
            Err(e) => {
                failures.push(format!(
                    "{}: roundtrip load failed: {}\n  dumped NT:\n{}",
                    name, e, dumped
                ));
                failed += 1;
            }
        }
    }

    eprintln!("\n=== Roundtrip Dump Test Results ===");
    eprintln!("Passed:  {}", passed);
    eprintln!("Failed:  {}", failed);
    eprintln!("Skipped: {}", skipped);
    eprintln!("Total:   {}", passed + failed + skipped);

    if !failures.is_empty() {
        eprintln!("\n=== Failures ===");
        for f in &failures {
            eprintln!("{}\n", f);
        }
    }

    assert!(
        failed == 0,
        "{} roundtrip test(s) failed out of {} (see above for details)",
        failed,
        passed + failed
    );
}