···1010# fixtures and we want to see them all in one run.
1111fail-fast = false
12121313-# Network-gated real-world roundtrip. Absorb flaky DNS/HTTP with a
1414-# couple of retries, warn loudly when a single attempt takes more than
1313+# Network-gated real-world roundtrip overrides. Absorb flaky DNS/HTTP
1414+# with a couple of retries, warn when a single attempt takes more than
1515# 30s (a dead resolver, usually), and hard-kill after 3 minutes to
1616-# prevent wedged CI. Deterministic tests still fail fast via the
1717-# default profile's settings.
1616+# prevent wedged CI. The binary filter covers every `roundtrip_*` test
1717+# added via the `roundtrip_test!` macro.
1818+#
1919+# No test-group here: nextest runs each test in its own OS process, so
2020+# `std::env::set_current_dir` calls are isolated per test. Each of the
2121+# 12 tests targets a different authority, so parallel DNS/HTTP is fine.
1822[[profile.default.overrides]]
1919-filter = "test(test_real_world_roundtrip)"
2323+filter = "binary(real_world_roundtrip)"
2024retries = 2
2125slow-timeout = { period = "30s", terminate-after = 6 }
+2-4
justfile
···5353 @echo "\nRunning workspace resolution tests..."
5454 cargo nextest run -p mlf-integration-tests --test workspace_integration
55555656-# Run real-world round-trip tests (network-dependent, ignored by default)
5656+# Run only the network-dependent real-world round-trip tests
5757test-real-world:
5858 @echo "\n🌐 Running real-world round-trip tests (fetches from network)..."
5959- @echo "This will download lexicons from: app.bsky.*, net.anisota.*, place.stream.*, pub.leaflet.*"
6060- @echo ""
6161- cargo nextest run -p mlf-integration-tests --test real_world_roundtrip --run-ignored only
5959+ cargo nextest run -p mlf-integration-tests --test real_world_roundtrip
62606361# Run tests with verbose output (stdout streamed live, tests run serially)
6462test-verbose:
+155-262
tests/real_world/roundtrip.rs
···11-// Real-world round-trip tests: JSON → MLF → JSON
11+// Real-world round-trip tests: JSON → MLF → JSON.
22//
33-// These tests fetch real lexicons from the network, convert them to MLF,
44-// then generate JSON back and verify the round-trip is accurate.
33+// Each test fetches real lexicons from the network for a single authority
44+// pattern (e.g. `app.bsky.actor.*`), converts them to MLF via the fetcher,
55+// regenerates JSON from the MLF using the codegen library, and verifies
66+// the round-trip is semantically accurate.
57//
66-// Run with: cargo test --test real_world_roundtrip -- --ignored --nocapture
88+// These tests hit the live network. Target one authority with:
99+//
1010+// cargo nextest run --test real_world_roundtrip roundtrip_app_bsky_actor
1111+//
1212+// Each test chdir's into its own tempdir before invoking the CLI library
1313+// API (which still reads the project root from the process CWD). That's
1414+// safe under nextest because nextest runs each test in its own OS
1515+// process, so CWD changes are isolated — the 12 roundtrips parallelise
1616+// freely.
717818use std::collections::HashSet;
919use std::fs;
1020use std::path::{Path, PathBuf};
1121use std::process::Command;
2222+2323+use mlf_cli::fetch::run_fetch;
2424+use mlf_cli::workspace_ext::load_mlf_directory;
2525+use mlf_codegen::generate_lexicon;
2626+use mlf_lang::Workspace;
1227use tempfile::TempDir;
13281414-/// Real-world lexicon sources to test
1515-/// These use specific namespaces that have DNS TXT records published
1616-const TEST_SOURCES: &[&str] = &[
1717- // Bluesky - use specific namespaces since top-level doesn't have TXT record
1818- "app.bsky.actor.*",
1919- "app.bsky.feed.*",
2020- "app.bsky.graph.*",
2121- // Other networks
2222- "net.anisota.*",
2323- "place.stream.*",
2424- "pub.leaflet.*",
2525-];
2929+/// Fetch the given authority pattern, round-trip every lexicon through
3030+/// MLF, and assert nothing structurally changed along the way.
3131+fn run_source_roundtrip(source: &str) {
3232+ println!("\n🌐 Round-trip for {}\n", source);
26332727-#[test]
2828-#[ignore] // Network-dependent test, run explicitly with --ignored
2929-fn test_real_world_roundtrip() {
3030- println!("\n🌐 Real-World Round-Trip Test");
3131- println!("=============================\n");
3232-3333- // Create temp directory for test workspace
3434 let temp_dir = TempDir::new().expect("Failed to create temp directory");
3535 let workspace_path = temp_dir.path();
36363737- println!("📁 Test workspace: {}\n", workspace_path.display());
3838-3939- // Step 1: Initialize MLF project
4040- println!("1️⃣ Initializing MLF project...");
4137 init_mlf_project(workspace_path).expect("Failed to initialize project");
42384343- // Step 2: Fetch real lexicons
4444- println!("\n2️⃣ Fetching real lexicons from network...");
4545- for source in TEST_SOURCES {
4646- println!(" Fetching: {}", source);
4747- fetch_lexicons(workspace_path, source).expect(&format!("Failed to fetch {}", source));
4848- }
3939+ // The CLI's fetch + generate APIs read the project root from the
4040+ // process's current working directory. Nextest runs each test in its
4141+ // own OS process, so this chdir is isolated — a sibling roundtrip
4242+ // running in parallel sees its own CWD.
4343+ std::env::set_current_dir(workspace_path)
4444+ .unwrap_or_else(|e| panic!("Failed to chdir to {}: {}", workspace_path.display(), e));
49455050- // Step 3: Copy MLF files to standard lexicons directory
5151- println!("\n3️⃣ Copying MLF files to standard lexicons directory...");
5252- let source_mlf_dir = workspace_path.join(".mlf/lexicons/mlf");
5353- let lexicons_dir = workspace_path.join("lexicons");
5454- copy_mlf_files(&source_mlf_dir, &lexicons_dir).expect("Failed to copy MLF files");
4646+ let runtime = tokio::runtime::Builder::new_current_thread()
4747+ .enable_all()
4848+ .build()
4949+ .expect("Failed to build tokio runtime");
5050+ runtime
5151+ .block_on(run_fetch(Some(source.to_string()), false, false, false))
5252+ .unwrap_or_else(|e| panic!("Failed to fetch {}: {:?}", source, e));
55535656- // Step 4: Generate JSON from MLF
5757- println!("\n4️⃣ Generating JSON from MLF files...");
5858- let output_dir = workspace_path.join("generated-lexicons");
5959- generate_json_from_mlf(workspace_path, &output_dir).expect("Failed to generate JSON");
5454+ let original_json_dir = workspace_path.join(".mlf/lexicons/json");
5555+ let fetched_mlf_dir = workspace_path.join(".mlf/lexicons/mlf");
60566161- // Step 5: Compare original vs regenerated JSON
6262- println!("\n5️⃣ Comparing original vs regenerated JSON...");
6363- let original_dir = workspace_path.join(".mlf/lexicons/json");
5757+ let regenerated = regenerate_lexicons_from_mlf(&fetched_mlf_dir)
5858+ .unwrap_or_else(|e| panic!("Failed to regenerate JSON: {}", e));
64596565- // Write diffs to tests/real_world/roundtrip/diffs/ (persisted, gitignored)
6666- let diffs_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6767- .join("real_world/roundtrip/diffs");
6060+ let diffs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6161+ .join("real_world/roundtrip/diffs")
6262+ .join(sanitize_source_name(source));
68636969- let stats = compare_json_files(&original_dir, &output_dir, &diffs_dir)
7070- .expect("Failed to compare JSON files");
6464+ let stats = compare_regenerated(&original_json_dir, ®enerated, &diffs_dir)
6565+ .unwrap_or_else(|e| panic!("Comparison failed: {}", e));
71667272- // Step 6: Report results
7373- println!("\n📊 Round-Trip Test Results");
7474- println!("===========================");
7575- println!("Total lexicons tested: {}", stats.total);
7676- println!("Perfect matches: {}", stats.perfect_matches);
7777- println!("Acceptable differences: {}", stats.acceptable_diffs);
7878- println!("Failures: {}", stats.failures);
6767+ println!(
6868+ "\n📊 {}: {} total, {} perfect, {} acceptable, {} failures",
6969+ source, stats.total, stats.perfect_matches, stats.acceptable_diffs, stats.failures
7070+ );
79718072 if !stats.failed_lexicons.is_empty() {
8181- println!("\n❌ Failed lexicons:");
7373+ println!("❌ Failed lexicons:");
8274 for (nsid, reason) in &stats.failed_lexicons {
8375 println!(" - {}: {}", nsid, reason);
8476 }
8577 }
86788779 if stats.acceptable_diffs > 0 || stats.failures > 0 {
8888- println!("\n📁 Diff files written to: {}", diffs_dir.display());
8989- println!(" Review these files to see what changed between original and regenerated JSON");
8080+ println!("📁 Diffs: {}", diffs_dir.display());
9081 }
91829292- // Assert that we have no failures
9383 assert_eq!(
9484 stats.failures, 0,
9595- "Round-trip test failed for {} lexicon(s). Check diff files in {}",
8585+ "Round-trip failed for {} lexicon(s) under {}. See {}",
9686 stats.failures,
8787+ source,
9788 diffs_dir.display()
9889 );
9090+}
9991100100- println!("\n✅ All round-trip tests passed!");
9292+fn sanitize_source_name(source: &str) -> String {
9393+ source.replace('.', "_").replace('*', "wildcard")
10194}
10295103103-/// Initialize an MLF project with mlf.toml
9696+/// Declare one `#[test]` per authority pattern so nextest can report
9797+/// failures per authority. Execution is rate-limited by the
9898+/// `real-world-network` test group in `.config/nextest.toml`.
9999+macro_rules! roundtrip_test {
100100+ ($name:ident, $source:expr) => {
101101+ #[test]
102102+ fn $name() {
103103+ run_source_roundtrip($source);
104104+ }
105105+ };
106106+}
107107+108108+// Bluesky — the top-level NSID has no TXT record, so we hit each category
109109+// individually.
110110+roundtrip_test!(roundtrip_app_bsky_actor, "app.bsky.actor.*");
111111+roundtrip_test!(roundtrip_app_bsky_feed, "app.bsky.feed.*");
112112+roundtrip_test!(roundtrip_app_bsky_graph, "app.bsky.graph.*");
113113+114114+// Other published lexicon authorities.
115115+roundtrip_test!(roundtrip_net_anisota, "net.anisota.*");
116116+roundtrip_test!(roundtrip_place_stream, "place.stream.*");
117117+roundtrip_test!(roundtrip_pub_leaflet, "pub.leaflet.*");
118118+roundtrip_test!(roundtrip_social_grain, "social.grain.*");
119119+roundtrip_test!(roundtrip_at_margin, "at.margin.*");
120120+roundtrip_test!(roundtrip_blog_pckt, "blog.pckt.*");
121121+122122+/// Write a minimal mlf.toml to the workspace root. `run_fetch` searches
123123+/// upward for this file to anchor the project; without it the fetch step
124124+/// would prompt interactively.
104125fn init_mlf_project(workspace_path: &Path) -> Result<(), String> {
105105- // Create mlf.toml
106126 let mlf_toml = r#"
107127[package]
108128name = "roundtrip-test"
···120140 Ok(())
121141}
122142123123-/// Fetch lexicons using `mlf fetch`
124124-fn fetch_lexicons(workspace_path: &Path, nsid_pattern: &str) -> Result<(), String> {
125125- let output = Command::new("mlf")
126126- .arg("fetch")
127127- .arg(nsid_pattern)
128128- .current_dir(workspace_path)
129129- .output()
130130- .map_err(|e| format!("Failed to execute mlf fetch: {}", e))?;
143143+/// Load every `.mlf` file fetched under the given directory into a
144144+/// workspace, resolve it, and regenerate Lexicon JSON for each module.
145145+/// Returns a map keyed by the on-disk path relative to `mlf_dir` (so
146146+/// callers can line up against the original JSON tree).
147147+fn regenerate_lexicons_from_mlf(mlf_dir: &Path) -> Result<Vec<(PathBuf, serde_json::Value)>, String> {
148148+ let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?;
149149+ load_mlf_directory(&mut ws, mlf_dir)?;
131150132132- if !output.status.success() {
133133- return Err(format!(
134134- "mlf fetch failed:\n{}",
135135- String::from_utf8_lossy(&output.stderr)
136136- ));
137137- }
151151+ ws.resolve()
152152+ .map_err(|e| format!("Failed to resolve workspace: {:?}", e))?;
138153139139- Ok(())
140140-}
154154+ let mut out = Vec::new();
155155+ for mlf_file in find_files_with_ext(mlf_dir, "mlf")? {
156156+ let relative = mlf_file
157157+ .strip_prefix(mlf_dir)
158158+ .map_err(|e| format!("Failed to strip prefix: {}", e))?
159159+ .to_path_buf();
141160142142-/// Copy MLF files from .mlf/lexicons/mlf to lexicons/
143143-fn copy_mlf_files(source_dir: &Path, dest_dir: &Path) -> Result<(), String> {
144144- if !source_dir.exists() {
145145- return Err(format!("Source directory not found: {}", source_dir.display()));
146146- }
161161+ let namespace = relative
162162+ .with_extension("")
163163+ .to_str()
164164+ .ok_or("Non-UTF8 path")?
165165+ .replace(std::path::MAIN_SEPARATOR, ".");
147166148148- fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
149149- fs::create_dir_all(dst)?;
150150- for entry in fs::read_dir(src)? {
151151- let entry = entry?;
152152- let src_path = entry.path();
153153- let dst_path = dst.join(entry.file_name());
167167+ let lexicon = ws
168168+ .get_lexicon(&namespace)
169169+ .ok_or_else(|| format!("Module not found in workspace: {}", namespace))?;
154170155155- if src_path.is_dir() {
156156- copy_recursive(&src_path, &dst_path)?;
157157- } else {
158158- fs::copy(&src_path, &dst_path)?;
159159- }
160160- }
161161- Ok(())
171171+ let json = generate_lexicon(&namespace, lexicon, &ws);
172172+ out.push((relative.with_extension("json"), json));
162173 }
163174164164- copy_recursive(source_dir, dest_dir)
165165- .map_err(|e| format!("Failed to copy MLF files: {}", e))?;
166166-167167- let mlf_count = find_mlf_files(dest_dir)?.len();
168168- println!(" Copied {} MLF files", mlf_count);
169169-170170- Ok(())
171171-}
172172-173173-/// Generate JSON from MLF files using `mlf generate lexicon`
174174-fn generate_json_from_mlf(workspace_dir: &Path, output_dir: &Path) -> Result<(), String> {
175175- let lexicons_dir = workspace_dir.join("lexicons");
176176-177177- if !lexicons_dir.exists() {
178178- return Err(format!("Lexicons directory not found: {}", lexicons_dir.display()));
179179- }
180180-181181- // Create output directory
182182- fs::create_dir_all(output_dir)
183183- .map_err(|e| format!("Failed to create output directory: {}", e))?;
184184-185185- // Generate all JSON files at once by passing the lexicons directory
186186- // This allows proper dependency resolution between MLF files
187187- println!(" Generating JSON files...");
188188- let output = Command::new("mlf")
189189- .arg("generate")
190190- .arg("lexicon")
191191- .arg("-i")
192192- .arg("lexicons")
193193- .arg("-o")
194194- .arg(output_dir)
195195- .current_dir(workspace_dir)
196196- .output()
197197- .map_err(|e| format!("Failed to execute mlf generate: {}", e))?;
198198-199199- if !output.status.success() {
200200- return Err(format!(
201201- "mlf generate lexicon failed:\n{}",
202202- String::from_utf8_lossy(&output.stderr)
203203- ));
204204- }
205205-206206- println!(" Generated JSON successfully");
207207-208208- Ok(())
175175+ Ok(out)
209176}
210177211211-/// Find all .mlf files recursively
212212-fn find_mlf_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
213213- let mut mlf_files = Vec::new();
178178+/// Recursively find files with the given extension under `dir`.
179179+fn find_files_with_ext(dir: &Path, ext: &str) -> Result<Vec<PathBuf>, String> {
180180+ let mut files = Vec::new();
214181215215- fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
216216- if dir.is_dir() {
217217- for entry in fs::read_dir(dir)? {
218218- let entry = entry?;
219219- let path = entry.path();
220220- if path.is_dir() {
221221- walk_dir(&path, files)?;
222222- } else if path.extension().and_then(|s| s.to_str()) == Some("mlf") {
223223- files.push(path);
224224- }
182182+ fn walk(dir: &Path, ext: &str, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
183183+ if !dir.is_dir() {
184184+ return Ok(());
185185+ }
186186+ for entry in fs::read_dir(dir)? {
187187+ let entry = entry?;
188188+ let path = entry.path();
189189+ if path.is_dir() {
190190+ walk(&path, ext, out)?;
191191+ } else if path.extension().and_then(|s| s.to_str()) == Some(ext) {
192192+ out.push(path);
225193 }
226194 }
227195 Ok(())
228196 }
229197230230- walk_dir(dir, &mut mlf_files).map_err(|e| format!("Failed to walk directory: {}", e))?;
231231- Ok(mlf_files)
198198+ walk(dir, ext, &mut files).map_err(|e| format!("Failed to walk directory: {}", e))?;
199199+ Ok(files)
232200}
233201234202#[derive(Debug)]
···240208 failed_lexicons: Vec<(String, String)>,
241209}
242210243243-/// Compare original JSON with regenerated JSON
244244-fn compare_json_files(
211211+fn compare_regenerated(
245212 original_dir: &Path,
246246- generated_dir: &Path,
213213+ regenerated: &[(PathBuf, serde_json::Value)],
247214 diffs_dir: &Path,
248215) -> Result<ComparisonStats, String> {
249249- // Create diffs directory
250216 fs::create_dir_all(diffs_dir)
251217 .map_err(|e| format!("Failed to create diffs directory: {}", e))?;
218218+252219 let mut stats = ComparisonStats {
253253- total: 0,
220220+ total: regenerated.len(),
254221 perfect_matches: 0,
255222 acceptable_diffs: 0,
256223 failures: 0,
257224 failed_lexicons: Vec::new(),
258225 };
259226260260- // Find all JSON files in original directory
261261- let original_files = find_json_files(original_dir)?;
262262- stats.total = original_files.len();
263263-264264- println!(" Comparing {} lexicon files...", stats.total);
265265-266266- for original_file in original_files {
267267- let relative_path = original_file
268268- .strip_prefix(original_dir)
269269- .map_err(|e| format!("Failed to strip prefix: {}", e))?;
270270-271271- let generated_file = generated_dir.join(relative_path);
272272-273273- // Extract NSID from path for reporting
227227+ for (relative_path, generated) in regenerated {
274228 let nsid = relative_path
275229 .with_extension("")
276230 .to_str()
277231 .unwrap()
278232 .replace(std::path::MAIN_SEPARATOR, ".");
279233280280- if !generated_file.exists() {
234234+ let original_path = original_dir.join(relative_path);
235235+ if !original_path.exists() {
281236 stats.failures += 1;
282282- stats.failed_lexicons
283283- .push((nsid.clone(), "Generated file not found".to_string()));
284284- eprintln!(" ✗ {}: Generated file not found", nsid);
237237+ stats
238238+ .failed_lexicons
239239+ .push((nsid, format!("Original file not found: {}", original_path.display())));
285240 continue;
286241 }
287242288288- // Read and parse JSON files
289289- let original_json = fs::read_to_string(&original_file)
243243+ let original_text = fs::read_to_string(&original_path)
290244 .map_err(|e| format!("Failed to read original: {}", e))?;
291291- let generated_json = fs::read_to_string(&generated_file)
292292- .map_err(|e| format!("Failed to read generated: {}", e))?;
293293-294294- let original: serde_json::Value = serde_json::from_str(&original_json)
245245+ let original: serde_json::Value = serde_json::from_str(&original_text)
295246 .map_err(|e| format!("Failed to parse original JSON: {}", e))?;
296296- let generated: serde_json::Value = serde_json::from_str(&generated_json)
297297- .map_err(|e| format!("Failed to parse generated JSON: {}", e))?;
247247+248248+ let generated_text = serde_json::to_string_pretty(generated)
249249+ .map_err(|e| format!("Failed to serialise generated JSON: {}", e))?;
298250299299- // Compare with allowed differences
300300- match compare_lexicon_json(&original, &generated) {
251251+ match compare_lexicon_json(&original, generated) {
301252 ComparisonResult::Perfect => {
302253 stats.perfect_matches += 1;
303303- println!(" ✓ {} (perfect match)", nsid);
304254 }
305255 ComparisonResult::AcceptableDifferences(diffs) => {
306256 stats.acceptable_diffs += 1;
307307- println!(" ✓ {} (acceptable diffs: {})", nsid, diffs.join(", "));
308308-309309- // Write diff file for acceptable differences
310310- write_diff_file(diffs_dir, &nsid, &original_json, &generated_json, "acceptable")
257257+ write_diff_file(diffs_dir, &nsid, &original_text, &generated_text, "acceptable")
311258 .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e));
259259+ println!(" ✓ {} (acceptable: {})", nsid, diffs.join(", "));
312260 }
313261 ComparisonResult::Failure(reason) => {
314262 stats.failures += 1;
315263 stats.failed_lexicons.push((nsid.clone(), reason.clone()));
264264+ write_diff_file(diffs_dir, &nsid, &original_text, &generated_text, "failure")
265265+ .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e));
316266 eprintln!(" ✗ {}: {}", nsid, reason);
317317-318318- // Write diff file for failures
319319- write_diff_file(diffs_dir, &nsid, &original_json, &generated_json, "failure")
320320- .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e));
321267 }
322268 }
323269 }
···325271 Ok(stats)
326272}
327273328328-/// Find all JSON files recursively
329329-fn find_json_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
330330- let mut json_files = Vec::new();
331331-332332- fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
333333- if dir.is_dir() {
334334- for entry in fs::read_dir(dir)? {
335335- let entry = entry?;
336336- let path = entry.path();
337337- if path.is_dir() {
338338- walk_dir(&path, files)?;
339339- } else if path.extension().and_then(|s| s.to_str()) == Some("json") {
340340- files.push(path);
341341- }
342342- }
343343- }
344344- Ok(())
345345- }
346346-347347- walk_dir(dir, &mut json_files).map_err(|e| format!("Failed to walk directory: {}", e))?;
348348- Ok(json_files)
349349-}
350350-351274#[derive(Debug)]
352275enum ComparisonResult {
353276 Perfect,
···362285) -> ComparisonResult {
363286 let mut acceptable_diffs = Vec::new();
364287365365- // Strip $type fields (these are often added/removed)
366288 let original_stripped = strip_dollar_type(original);
367289 let generated_stripped = strip_dollar_type(generated);
368290369369- // Check if they're identical after stripping $type
370291 if original_stripped == generated_stripped {
371292 return ComparisonResult::Perfect;
372293 }
373294374374- // Allow $type differences
375375- if has_only_dollar_type_diff(&original_stripped, &generated_stripped) {
376376- acceptable_diffs.push("$type fields".to_string());
377377- }
378378-379379- // Check for field ordering differences (same fields, different order)
295295+ // The strip above already handled $type-only differences; detect
296296+ // field ordering next since our regen emits in declaration order.
380297 if has_only_ordering_diff(&original_stripped, &generated_stripped) {
381298 acceptable_diffs.push("field ordering".to_string());
382299 return ComparisonResult::AcceptableDifferences(acceptable_diffs);
383300 }
384301385385- // If we have acceptable diffs, return them
386386- if !acceptable_diffs.is_empty() {
387387- return ComparisonResult::AcceptableDifferences(acceptable_diffs);
388388- }
389389-390390- // Otherwise, it's a failure
391391- ComparisonResult::Failure(format!(
392392- "Structural differences detected"
393393- ))
302302+ ComparisonResult::Failure("Structural differences detected".to_string())
394303}
395304396396-/// Recursively strip $type fields from JSON
397305fn strip_dollar_type(value: &serde_json::Value) -> serde_json::Value {
398306 match value {
399307 serde_json::Value::Object(map) => {
···412320 }
413321}
414322415415-/// Check if the only difference is $type fields
416416-fn has_only_dollar_type_diff(v1: &serde_json::Value, v2: &serde_json::Value) -> bool {
417417- // After stripping $type, they should be equal
418418- v1 == v2
419419-}
420420-421421-/// Write diff files showing differences between original and generated JSON
422323fn write_diff_file(
423324 diffs_dir: &Path,
424325 nsid: &str,
···426327 generated_json: &str,
427328 diff_type: &str,
428329) -> Result<(), String> {
429429- // Create subdirectory based on diff type
430330 let type_dir = diffs_dir.join(diff_type);
431331 fs::create_dir_all(&type_dir)
432332 .map_err(|e| format!("Failed to create diff type directory: {}", e))?;
433333434434- // Create base filename from NSID
435334 let base_filename = nsid.replace('.', "_");
436335437437- // Write original JSON
438336 let original_path = type_dir.join(format!("{}.original.json", base_filename));
439337 fs::write(&original_path, original_json)
440338 .map_err(|e| format!("Failed to write original JSON: {}", e))?;
441339442442- // Write generated JSON
443340 let generated_path = type_dir.join(format!("{}.generated.json", base_filename));
444341 fs::write(&generated_path, generated_json)
445342 .map_err(|e| format!("Failed to write generated JSON: {}", e))?;
446343447447- // Run diff command and save output
448344 let diff_path = type_dir.join(format!("{}.diff", base_filename));
449345 let diff_output = Command::new("diff")
450346 .arg("-u")
···453349 .output()
454350 .map_err(|e| format!("Failed to run diff command: {}", e))?;
455351456456- // diff returns exit code 1 when files differ, which is expected
457457- // Only error if exit code is 2+ (indicates an error running diff)
352352+ // diff exit code 1 just means "files differ" — only error on 2+.
458353 if diff_output.status.code() == Some(2) {
459459- return Err(format!("diff command error: {}", String::from_utf8_lossy(&diff_output.stderr)));
354354+ return Err(format!(
355355+ "diff command error: {}",
356356+ String::from_utf8_lossy(&diff_output.stderr)
357357+ ));
460358 }
461359462462- // Write diff output
463360 fs::write(&diff_path, &diff_output.stdout)
464361 .map_err(|e| format!("Failed to write diff output: {}", e))?;
465362466363 Ok(())
467364}
468365469469-/// Check if the only difference is field ordering in objects
470366fn has_only_ordering_diff(v1: &serde_json::Value, v2: &serde_json::Value) -> bool {
471367 match (v1, v2) {
472368 (serde_json::Value::Object(map1), serde_json::Value::Object(map2)) => {
473473- // Check if they have the same keys
474369 let keys1: HashSet<_> = map1.keys().collect();
475370 let keys2: HashSet<_> = map2.keys().collect();
476371···478373 return false;
479374 }
480375481481- // Check if all values match (recursively)
482376 for key in keys1 {
483377 let val1 = &map1[key];
484378 let val2 = &map2[key];
···491385 true
492386 }
493387 (serde_json::Value::Array(arr1), serde_json::Value::Array(arr2)) => {
494494- // Arrays must match exactly (order matters)
495388 if arr1.len() != arr2.len() {
496389 return false;
497390 }