mod common;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::{json, Value as Json};
use common::{fixture_parser, repo_root, to_json};
fn suites_dir() -> PathBuf {
repo_root().join("test").join("suites")
}
const MISSING: &str = "conformance corpus missing under test/suites. The corpora are \
vendored: restore them with `git checkout -- test/suites` (see test/suites/README.md). \
This fails rather than skips on purpose: a conformance suite that quietly does not run \
reports green while measuring nothing.";
fn require_corpus(path: &Path) {
assert!(path.exists(), "{MISSING}\n expected: {}", path.display());
}
fn read_lossy(path: &Path) -> String {
let bytes = fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
String::from_utf8_lossy(&bytes).into_owned()
}
fn canon(value: &Json) -> String {
match value {
Json::Array(items) => {
let inner: Vec<String> = items.iter().map(canon).collect();
format!("[{}]", inner.join(","))
}
Json::Object(entries) => {
let ordered: BTreeMap<&String, &Json> = entries.iter().collect();
let inner: Vec<String> = ordered
.iter()
.map(|(key, value)| format!("{}:{}", Json::String((*key).clone()), canon(value)))
.collect();
format!("{{{}}}", inner.join(","))
}
Json::Number(number) => number
.as_f64()
.map(|float| {
if float.fract() == 0.0 && float.abs() < 1e15 {
format!("{float:.0}")
} else {
float.to_string()
}
})
.unwrap_or_else(|| number.to_string()),
scalar => scalar.to_string(),
}
}
fn show(text: &str) -> String {
if text.chars().count() > 160 {
let cut: String = text.chars().take(160).collect();
format!("{cut}…")
} else {
text.to_string()
}
}
fn report(label: &str, total: usize, failures: &[String]) {
assert!(
failures.is_empty(),
"{label}: {}/{total} passed. FAILING ({}):\n - {}",
total - failures.len(),
failures.len(),
failures.join("\n - ")
);
eprintln!("{label}: {total}/{total} passed");
}
const SPECTRUM_UPSTREAM_DEFECT: &str = "location_coordinates";
fn spectrum_dirs() -> (PathBuf, PathBuf) {
let dir = suites_dir().join("csv-spectrum");
let csv_dir = dir.join("csvs");
let json_dir = dir.join("json");
for path in [&dir, &csv_dir, &json_dir] {
require_corpus(path);
}
(csv_dir, json_dir)
}
#[test]
fn spectrum_valid_documents_parse_to_the_expected_value() {
let (csv_dir, json_dir) = spectrum_dirs();
let mut names: Vec<String> = fs::read_dir(&csv_dir)
.unwrap_or_else(|error| panic!("{MISSING}\n {error}"))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter_map(|name| name.strip_suffix(".csv").map(str::to_string))
.collect();
names.sort();
assert!(
!names.is_empty(),
"{MISSING}\n found no .csv documents in {}",
csv_dir.display()
);
let parser = tabnas_csv::make();
let mut failures = Vec::new();
let mut judged = 0;
for name in &names {
if name == SPECTRUM_UPSTREAM_DEFECT {
continue; }
judged += 1;
let src = read_lossy(&csv_dir.join(format!("{name}.csv")));
let want: Json = serde_json::from_str(&read_lossy(&json_dir.join(format!("{name}.json"))))
.unwrap_or_else(|error| panic!("{name}.json: {error}"));
match parser.parse(&src) {
Err(error) => failures.push(format!("{name}: threw {}", error.code)),
Ok(value) => {
let got = canon(&to_json(&value));
let expected = canon(&want);
if got != expected {
failures.push(format!(
"{name}: expected {} got {}",
show(&expected),
show(&got)
));
}
}
}
}
assert_eq!(
judged,
names.len() - 1,
"every corpus document must be judged"
);
report("csv-spectrum valid", judged, &failures);
}
#[test]
fn spectrum_upstream_defect_location_coordinates_contradicts_itself() {
let (csv_dir, json_dir) = spectrum_dirs();
let src = read_lossy(&csv_dir.join(format!("{SPECTRUM_UPSTREAM_DEFECT}.csv")));
let expected: Json = serde_json::from_str(&read_lossy(
&json_dir.join(format!("{SPECTRUM_UPSTREAM_DEFECT}.json")),
))
.expect("the expectation is JSON");
let Json::Object(object) = &expected else {
panic!(
"upstream csv-spectrum fixed the shape of {SPECTRUM_UPSTREAM_DEFECT}.json: delete \
SPECTRUM_UPSTREAM_DEFECT and judge this case normally"
);
};
assert_eq!(
object.get("Contact Phone Number"),
Some(&json!("1234567890")),
"upstream csv-spectrum changed the scrubbed phone number: re-check"
);
assert!(
src.contains("2095257564"),
"upstream csv-spectrum changed {SPECTRUM_UPSTREAM_DEFECT}.csv: re-check"
);
let value = tabnas_csv::make().parse(&src).expect("parses");
let got = to_json(&value);
let Json::Array(records) = &got else {
panic!("expected an array of records, got {got}");
};
assert_eq!(records.len(), 1);
let Json::Object(record) = &records[0] else {
panic!("expected an object record, got {}", records[0]);
};
let keys: Vec<&String> = record.keys().collect();
assert_eq!(
keys,
[
"Contact Phone Number",
"Location Coordinates",
"Cities",
"Counties"
]
);
assert_eq!(record["Contact Phone Number"], json!("2095257564"));
assert_eq!(
canon(&got),
r#"[{"Cities":"Modesto","Contact Phone Number":"2095257564","Counties":"Stanislaus","Location Coordinates":"37�36'37.8\"N 121�2'17.9\"W"}]"#
);
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GoCase {
name: String,
input: String,
must_fail: bool,
#[serde(default)]
expected: Json,
#[serde(default)]
opts: Json,
#[serde(default)]
jsonic_opts: Json,
#[serde(default)]
profile: String,
}
#[derive(Debug, Deserialize)]
struct Excluded {
name: String,
}
#[derive(Debug, Deserialize)]
struct GoCorpus {
cases: Vec<GoCase>,
#[serde(default)]
excluded: Vec<Excluded>,
}
fn divergences() -> BTreeMap<&'static str, (&'static str, &'static str)> {
let bare_cr = "bare-CR-is-a-record-separator";
let stray = "stray-quotes-are-literal-text";
let exact = "field.exact-is-header-relative";
BTreeMap::from([
("BareCR", (bare_cr, r#"[["a","b"],["c","d"]]"#)),
("FieldCR", (bare_cr, r#"[["field"],["field"]]"#)),
("FieldCRCR", (bare_cr, r#"[["field"],["field"]]"#)),
("FieldCRCRLF", (bare_cr, r#"[["field"],["field"]]"#)),
("FieldCRCRLFCR", (bare_cr, r#"[["field"],["field"]]"#)),
("FieldCRCRLFCRCR", (bare_cr, r#"[["field"],["field"]]"#)),
(
"MultiFieldCRCRLFCRCR",
(
bare_cr,
r#"[["field1","field2"],["field1","field2"],["",""]]"#,
),
),
("QuotedTrailingCRCR", (bare_cr, r#"[["field"]]"#)),
(
"CRLFInQuotedField",
(
"quoted-CRLF-is-preserved-verbatim",
r#"[["A","Hello\r\nHi","B"]]"#,
),
),
("BadDoubleQuotes", (stray, r#"[["a\"\"b","c"]]"#)),
("BadBareQuote", (stray, r#"[["a \"word\"","b"]]"#)),
("BadTrailingQuote", (stray, r#"[["a word","b\""]]"#)),
(
"TrimQuote",
(
"whitespace-then-quote-is-literal-text",
r#"[["\"a\""," b","c"]]"#,
),
),
("BadFieldCount", (exact, r#"[["a","b","c"],["d","e"]]"#)),
(
"BadFieldCountMultiple",
(exact, r#"[["a","b","c"],["d","e"],["f"]]"#),
),
("BadFieldCount1", (exact, r#"[["a","b","c"]]"#)),
])
}
const CONFORMANCE_SCORE: usize = 39;
fn load_go_corpus() -> GoCorpus {
let file = suites_dir().join("go-encoding-csv").join("cases.json");
require_corpus(&file);
let corpus: GoCorpus = serde_json::from_str(&read_lossy(&file))
.unwrap_or_else(|error| panic!("corpus at {} is not readable: {error}", file.display()));
assert!(
!corpus.cases.is_empty(),
"{MISSING}\n corpus at {} has no cases",
file.display()
);
corpus
}
fn run_go_case(case: &GoCase) -> String {
let options = if case.opts.is_object() {
case.opts.clone()
} else {
json!({})
};
let jsonic_opts = case.jsonic_opts.is_object().then_some(&case.jsonic_opts);
match fixture_parser(&options, jsonic_opts).parse(&case.input) {
Ok(value) => canon(&to_json(&value)),
Err(_) => "ERROR".to_string(),
}
}
#[test]
fn go_encoding_csv_valid_documents_parse_to_the_expected_value() {
let corpus = load_go_corpus();
let divergences = divergences();
let mut failures = Vec::new();
let mut total = 0;
for case in corpus.cases.iter().filter(|case| !case.must_fail) {
total += 1;
let got = run_go_case(case);
let want = canon(&case.expected);
if let Some((why, pinned)) = divergences.get(case.name.as_str()) {
if got != *pinned {
failures.push(format!(
"{} [divergence {why}]: pinned {} but got {}: update divergences or fix the regression",
case.name,
show(pinned),
show(&got)
));
} else if got == want {
failures.push(format!(
"{}: listed as a divergence but now MATCHES the corpus: delete the divergences entry",
case.name
));
}
continue;
}
if got != want {
failures.push(format!(
"{} [{}]: expected {} got {}",
case.name,
case.profile,
show(&want),
show(&got)
));
}
}
report("go/encoding/csv valid", total, &failures);
}
#[test]
fn go_encoding_csv_invalid_documents_are_rejected() {
let corpus = load_go_corpus();
let divergences = divergences();
let mut failures = Vec::new();
let mut total = 0;
for case in corpus.cases.iter().filter(|case| case.must_fail) {
total += 1;
let got = run_go_case(case);
if let Some((why, pinned)) = divergences.get(case.name.as_str()) {
if got != *pinned {
failures.push(format!(
"{} [divergence {why}]: pinned {} but got {}: update divergences or fix the regression",
case.name,
show(pinned),
show(&got)
));
} else if got == "ERROR" {
failures.push(format!(
"{}: listed as a divergence but is now REJECTED like the corpus requires: delete the divergences entry",
case.name
));
}
continue;
}
if got != "ERROR" {
failures.push(format!(
"{} [{}]: was ACCEPTED as {} but RFC 4180 / encoding/csv rejects it",
case.name,
case.profile,
show(&got)
));
}
}
report("go/encoding/csv invalid-rejected", total, &failures);
}
#[test]
fn the_conformance_score_is_exactly_the_documented_one() {
let corpus = load_go_corpus();
let divergences = divergences();
for name in divergences.keys() {
assert!(
corpus.cases.iter().any(|case| case.name == *name),
"divergences names a case not in the corpus: {name}"
);
}
assert_eq!(
corpus.cases.len() - divergences.len(),
CONFORMANCE_SCORE,
"documented in AGENTS.md as {CONFORMANCE_SCORE}/{} go/encoding/csv cases conforming; \
update AGENTS.md and README.md together with this number",
corpus.cases.len()
);
assert_eq!(divergences.len(), 16, "AGENTS.md documents 16 divergences");
}
#[test]
fn the_excluded_set_is_exactly_the_documented_one() {
let corpus = load_go_corpus();
let mut got: Vec<&str> = corpus
.excluded
.iter()
.map(|excluded| excluded.name.as_str())
.collect();
got.sort_unstable();
let mut want = vec![
"BareDoubleQuotes",
"BareQuotes",
"LazyOddQuotes",
"LazyQuoteWithTrailingCRLF",
"LazyQuotes",
"BadComma1",
"BadComma2",
"BadComma3",
"BadComma4",
"BadCommaComment",
"BadComment1",
"BadComment2",
"BadComment3",
];
want.sort_unstable();
assert_eq!(got, want, "excluded set drifted");
}