A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

More lexicons for roundtrip

authored by stavola.xyz and committed by

Tangled d682d09a 2e7ce70a

+166 -271
+9 -5
.config/nextest.toml
··· 10 10 # fixtures and we want to see them all in one run. 11 11 fail-fast = false 12 12 13 - # Network-gated real-world roundtrip. Absorb flaky DNS/HTTP with a 14 - # couple of retries, warn loudly when a single attempt takes more than 13 + # Network-gated real-world roundtrip overrides. Absorb flaky DNS/HTTP 14 + # with a couple of retries, warn when a single attempt takes more than 15 15 # 30s (a dead resolver, usually), and hard-kill after 3 minutes to 16 - # prevent wedged CI. Deterministic tests still fail fast via the 17 - # default profile's settings. 16 + # prevent wedged CI. The binary filter covers every `roundtrip_*` test 17 + # added via the `roundtrip_test!` macro. 18 + # 19 + # No test-group here: nextest runs each test in its own OS process, so 20 + # `std::env::set_current_dir` calls are isolated per test. Each of the 21 + # 12 tests targets a different authority, so parallel DNS/HTTP is fine. 18 22 [[profile.default.overrides]] 19 - filter = "test(test_real_world_roundtrip)" 23 + filter = "binary(real_world_roundtrip)" 20 24 retries = 2 21 25 slow-timeout = { period = "30s", terminate-after = 6 }
+2 -4
justfile
··· 53 53 @echo "\nRunning workspace resolution tests..." 54 54 cargo nextest run -p mlf-integration-tests --test workspace_integration 55 55 56 - # Run real-world round-trip tests (network-dependent, ignored by default) 56 + # Run only the network-dependent real-world round-trip tests 57 57 test-real-world: 58 58 @echo "\n🌐 Running real-world round-trip tests (fetches from network)..." 59 - @echo "This will download lexicons from: app.bsky.*, net.anisota.*, place.stream.*, pub.leaflet.*" 60 - @echo "" 61 - cargo nextest run -p mlf-integration-tests --test real_world_roundtrip --run-ignored only 59 + cargo nextest run -p mlf-integration-tests --test real_world_roundtrip 62 60 63 61 # Run tests with verbose output (stdout streamed live, tests run serially) 64 62 test-verbose:
+155 -262
tests/real_world/roundtrip.rs
··· 1 - // Real-world round-trip tests: JSON → MLF → JSON 1 + // Real-world round-trip tests: JSON → MLF → JSON. 2 2 // 3 - // These tests fetch real lexicons from the network, convert them to MLF, 4 - // then generate JSON back and verify the round-trip is accurate. 3 + // Each test fetches real lexicons from the network for a single authority 4 + // pattern (e.g. `app.bsky.actor.*`), converts them to MLF via the fetcher, 5 + // regenerates JSON from the MLF using the codegen library, and verifies 6 + // the round-trip is semantically accurate. 5 7 // 6 - // Run with: cargo test --test real_world_roundtrip -- --ignored --nocapture 8 + // These tests hit the live network. Target one authority with: 9 + // 10 + // cargo nextest run --test real_world_roundtrip roundtrip_app_bsky_actor 11 + // 12 + // Each test chdir's into its own tempdir before invoking the CLI library 13 + // API (which still reads the project root from the process CWD). That's 14 + // safe under nextest because nextest runs each test in its own OS 15 + // process, so CWD changes are isolated — the 12 roundtrips parallelise 16 + // freely. 7 17 8 18 use std::collections::HashSet; 9 19 use std::fs; 10 20 use std::path::{Path, PathBuf}; 11 21 use std::process::Command; 22 + 23 + use mlf_cli::fetch::run_fetch; 24 + use mlf_cli::workspace_ext::load_mlf_directory; 25 + use mlf_codegen::generate_lexicon; 26 + use mlf_lang::Workspace; 12 27 use tempfile::TempDir; 13 28 14 - /// Real-world lexicon sources to test 15 - /// These use specific namespaces that have DNS TXT records published 16 - const TEST_SOURCES: &[&str] = &[ 17 - // Bluesky - use specific namespaces since top-level doesn't have TXT record 18 - "app.bsky.actor.*", 19 - "app.bsky.feed.*", 20 - "app.bsky.graph.*", 21 - // Other networks 22 - "net.anisota.*", 23 - "place.stream.*", 24 - "pub.leaflet.*", 25 - ]; 29 + /// Fetch the given authority pattern, round-trip every lexicon through 30 + /// MLF, and assert nothing structurally changed along the way. 31 + fn run_source_roundtrip(source: &str) { 32 + println!("\n🌐 Round-trip for {}\n", source); 26 33 27 - #[test] 28 - #[ignore] // Network-dependent test, run explicitly with --ignored 29 - fn test_real_world_roundtrip() { 30 - println!("\n🌐 Real-World Round-Trip Test"); 31 - println!("=============================\n"); 32 - 33 - // Create temp directory for test workspace 34 34 let temp_dir = TempDir::new().expect("Failed to create temp directory"); 35 35 let workspace_path = temp_dir.path(); 36 36 37 - println!("📁 Test workspace: {}\n", workspace_path.display()); 38 - 39 - // Step 1: Initialize MLF project 40 - println!("1️⃣ Initializing MLF project..."); 41 37 init_mlf_project(workspace_path).expect("Failed to initialize project"); 42 38 43 - // Step 2: Fetch real lexicons 44 - println!("\n2️⃣ Fetching real lexicons from network..."); 45 - for source in TEST_SOURCES { 46 - println!(" Fetching: {}", source); 47 - fetch_lexicons(workspace_path, source).expect(&format!("Failed to fetch {}", source)); 48 - } 39 + // The CLI's fetch + generate APIs read the project root from the 40 + // process's current working directory. Nextest runs each test in its 41 + // own OS process, so this chdir is isolated — a sibling roundtrip 42 + // running in parallel sees its own CWD. 43 + std::env::set_current_dir(workspace_path) 44 + .unwrap_or_else(|e| panic!("Failed to chdir to {}: {}", workspace_path.display(), e)); 49 45 50 - // Step 3: Copy MLF files to standard lexicons directory 51 - println!("\n3️⃣ Copying MLF files to standard lexicons directory..."); 52 - let source_mlf_dir = workspace_path.join(".mlf/lexicons/mlf"); 53 - let lexicons_dir = workspace_path.join("lexicons"); 54 - copy_mlf_files(&source_mlf_dir, &lexicons_dir).expect("Failed to copy MLF files"); 46 + let runtime = tokio::runtime::Builder::new_current_thread() 47 + .enable_all() 48 + .build() 49 + .expect("Failed to build tokio runtime"); 50 + runtime 51 + .block_on(run_fetch(Some(source.to_string()), false, false, false)) 52 + .unwrap_or_else(|e| panic!("Failed to fetch {}: {:?}", source, e)); 55 53 56 - // Step 4: Generate JSON from MLF 57 - println!("\n4️⃣ Generating JSON from MLF files..."); 58 - let output_dir = workspace_path.join("generated-lexicons"); 59 - generate_json_from_mlf(workspace_path, &output_dir).expect("Failed to generate JSON"); 54 + let original_json_dir = workspace_path.join(".mlf/lexicons/json"); 55 + let fetched_mlf_dir = workspace_path.join(".mlf/lexicons/mlf"); 60 56 61 - // Step 5: Compare original vs regenerated JSON 62 - println!("\n5️⃣ Comparing original vs regenerated JSON..."); 63 - let original_dir = workspace_path.join(".mlf/lexicons/json"); 57 + let regenerated = regenerate_lexicons_from_mlf(&fetched_mlf_dir) 58 + .unwrap_or_else(|e| panic!("Failed to regenerate JSON: {}", e)); 64 59 65 - // Write diffs to tests/real_world/roundtrip/diffs/ (persisted, gitignored) 66 - let diffs_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) 67 - .join("real_world/roundtrip/diffs"); 60 + let diffs_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 61 + .join("real_world/roundtrip/diffs") 62 + .join(sanitize_source_name(source)); 68 63 69 - let stats = compare_json_files(&original_dir, &output_dir, &diffs_dir) 70 - .expect("Failed to compare JSON files"); 64 + let stats = compare_regenerated(&original_json_dir, &regenerated, &diffs_dir) 65 + .unwrap_or_else(|e| panic!("Comparison failed: {}", e)); 71 66 72 - // Step 6: Report results 73 - println!("\n📊 Round-Trip Test Results"); 74 - println!("==========================="); 75 - println!("Total lexicons tested: {}", stats.total); 76 - println!("Perfect matches: {}", stats.perfect_matches); 77 - println!("Acceptable differences: {}", stats.acceptable_diffs); 78 - println!("Failures: {}", stats.failures); 67 + println!( 68 + "\n📊 {}: {} total, {} perfect, {} acceptable, {} failures", 69 + source, stats.total, stats.perfect_matches, stats.acceptable_diffs, stats.failures 70 + ); 79 71 80 72 if !stats.failed_lexicons.is_empty() { 81 - println!("\n❌ Failed lexicons:"); 73 + println!("❌ Failed lexicons:"); 82 74 for (nsid, reason) in &stats.failed_lexicons { 83 75 println!(" - {}: {}", nsid, reason); 84 76 } 85 77 } 86 78 87 79 if stats.acceptable_diffs > 0 || stats.failures > 0 { 88 - println!("\n📁 Diff files written to: {}", diffs_dir.display()); 89 - println!(" Review these files to see what changed between original and regenerated JSON"); 80 + println!("📁 Diffs: {}", diffs_dir.display()); 90 81 } 91 82 92 - // Assert that we have no failures 93 83 assert_eq!( 94 84 stats.failures, 0, 95 - "Round-trip test failed for {} lexicon(s). Check diff files in {}", 85 + "Round-trip failed for {} lexicon(s) under {}. See {}", 96 86 stats.failures, 87 + source, 97 88 diffs_dir.display() 98 89 ); 90 + } 99 91 100 - println!("\n✅ All round-trip tests passed!"); 92 + fn sanitize_source_name(source: &str) -> String { 93 + source.replace('.', "_").replace('*', "wildcard") 101 94 } 102 95 103 - /// Initialize an MLF project with mlf.toml 96 + /// Declare one `#[test]` per authority pattern so nextest can report 97 + /// failures per authority. Execution is rate-limited by the 98 + /// `real-world-network` test group in `.config/nextest.toml`. 99 + macro_rules! roundtrip_test { 100 + ($name:ident, $source:expr) => { 101 + #[test] 102 + fn $name() { 103 + run_source_roundtrip($source); 104 + } 105 + }; 106 + } 107 + 108 + // Bluesky — the top-level NSID has no TXT record, so we hit each category 109 + // individually. 110 + roundtrip_test!(roundtrip_app_bsky_actor, "app.bsky.actor.*"); 111 + roundtrip_test!(roundtrip_app_bsky_feed, "app.bsky.feed.*"); 112 + roundtrip_test!(roundtrip_app_bsky_graph, "app.bsky.graph.*"); 113 + 114 + // Other published lexicon authorities. 115 + roundtrip_test!(roundtrip_net_anisota, "net.anisota.*"); 116 + roundtrip_test!(roundtrip_place_stream, "place.stream.*"); 117 + roundtrip_test!(roundtrip_pub_leaflet, "pub.leaflet.*"); 118 + roundtrip_test!(roundtrip_social_grain, "social.grain.*"); 119 + roundtrip_test!(roundtrip_at_margin, "at.margin.*"); 120 + roundtrip_test!(roundtrip_blog_pckt, "blog.pckt.*"); 121 + 122 + /// Write a minimal mlf.toml to the workspace root. `run_fetch` searches 123 + /// upward for this file to anchor the project; without it the fetch step 124 + /// would prompt interactively. 104 125 fn init_mlf_project(workspace_path: &Path) -> Result<(), String> { 105 - // Create mlf.toml 106 126 let mlf_toml = r#" 107 127 [package] 108 128 name = "roundtrip-test" ··· 120 140 Ok(()) 121 141 } 122 142 123 - /// Fetch lexicons using `mlf fetch` 124 - fn fetch_lexicons(workspace_path: &Path, nsid_pattern: &str) -> Result<(), String> { 125 - let output = Command::new("mlf") 126 - .arg("fetch") 127 - .arg(nsid_pattern) 128 - .current_dir(workspace_path) 129 - .output() 130 - .map_err(|e| format!("Failed to execute mlf fetch: {}", e))?; 143 + /// Load every `.mlf` file fetched under the given directory into a 144 + /// workspace, resolve it, and regenerate Lexicon JSON for each module. 145 + /// Returns a map keyed by the on-disk path relative to `mlf_dir` (so 146 + /// callers can line up against the original JSON tree). 147 + fn regenerate_lexicons_from_mlf(mlf_dir: &Path) -> Result<Vec<(PathBuf, serde_json::Value)>, String> { 148 + let mut ws = Workspace::with_std().map_err(|e| format!("Failed to create workspace: {:?}", e))?; 149 + load_mlf_directory(&mut ws, mlf_dir)?; 131 150 132 - if !output.status.success() { 133 - return Err(format!( 134 - "mlf fetch failed:\n{}", 135 - String::from_utf8_lossy(&output.stderr) 136 - )); 137 - } 151 + ws.resolve() 152 + .map_err(|e| format!("Failed to resolve workspace: {:?}", e))?; 138 153 139 - Ok(()) 140 - } 154 + let mut out = Vec::new(); 155 + for mlf_file in find_files_with_ext(mlf_dir, "mlf")? { 156 + let relative = mlf_file 157 + .strip_prefix(mlf_dir) 158 + .map_err(|e| format!("Failed to strip prefix: {}", e))? 159 + .to_path_buf(); 141 160 142 - /// Copy MLF files from .mlf/lexicons/mlf to lexicons/ 143 - fn copy_mlf_files(source_dir: &Path, dest_dir: &Path) -> Result<(), String> { 144 - if !source_dir.exists() { 145 - return Err(format!("Source directory not found: {}", source_dir.display())); 146 - } 161 + let namespace = relative 162 + .with_extension("") 163 + .to_str() 164 + .ok_or("Non-UTF8 path")? 165 + .replace(std::path::MAIN_SEPARATOR, "."); 147 166 148 - fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { 149 - fs::create_dir_all(dst)?; 150 - for entry in fs::read_dir(src)? { 151 - let entry = entry?; 152 - let src_path = entry.path(); 153 - let dst_path = dst.join(entry.file_name()); 167 + let lexicon = ws 168 + .get_lexicon(&namespace) 169 + .ok_or_else(|| format!("Module not found in workspace: {}", namespace))?; 154 170 155 - if src_path.is_dir() { 156 - copy_recursive(&src_path, &dst_path)?; 157 - } else { 158 - fs::copy(&src_path, &dst_path)?; 159 - } 160 - } 161 - Ok(()) 171 + let json = generate_lexicon(&namespace, lexicon, &ws); 172 + out.push((relative.with_extension("json"), json)); 162 173 } 163 174 164 - copy_recursive(source_dir, dest_dir) 165 - .map_err(|e| format!("Failed to copy MLF files: {}", e))?; 166 - 167 - let mlf_count = find_mlf_files(dest_dir)?.len(); 168 - println!(" Copied {} MLF files", mlf_count); 169 - 170 - Ok(()) 171 - } 172 - 173 - /// Generate JSON from MLF files using `mlf generate lexicon` 174 - fn generate_json_from_mlf(workspace_dir: &Path, output_dir: &Path) -> Result<(), String> { 175 - let lexicons_dir = workspace_dir.join("lexicons"); 176 - 177 - if !lexicons_dir.exists() { 178 - return Err(format!("Lexicons directory not found: {}", lexicons_dir.display())); 179 - } 180 - 181 - // Create output directory 182 - fs::create_dir_all(output_dir) 183 - .map_err(|e| format!("Failed to create output directory: {}", e))?; 184 - 185 - // Generate all JSON files at once by passing the lexicons directory 186 - // This allows proper dependency resolution between MLF files 187 - println!(" Generating JSON files..."); 188 - let output = Command::new("mlf") 189 - .arg("generate") 190 - .arg("lexicon") 191 - .arg("-i") 192 - .arg("lexicons") 193 - .arg("-o") 194 - .arg(output_dir) 195 - .current_dir(workspace_dir) 196 - .output() 197 - .map_err(|e| format!("Failed to execute mlf generate: {}", e))?; 198 - 199 - if !output.status.success() { 200 - return Err(format!( 201 - "mlf generate lexicon failed:\n{}", 202 - String::from_utf8_lossy(&output.stderr) 203 - )); 204 - } 205 - 206 - println!(" Generated JSON successfully"); 207 - 208 - Ok(()) 175 + Ok(out) 209 176 } 210 177 211 - /// Find all .mlf files recursively 212 - fn find_mlf_files(dir: &Path) -> Result<Vec<PathBuf>, String> { 213 - let mut mlf_files = Vec::new(); 178 + /// Recursively find files with the given extension under `dir`. 179 + fn find_files_with_ext(dir: &Path, ext: &str) -> Result<Vec<PathBuf>, String> { 180 + let mut files = Vec::new(); 214 181 215 - fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> { 216 - if dir.is_dir() { 217 - for entry in fs::read_dir(dir)? { 218 - let entry = entry?; 219 - let path = entry.path(); 220 - if path.is_dir() { 221 - walk_dir(&path, files)?; 222 - } else if path.extension().and_then(|s| s.to_str()) == Some("mlf") { 223 - files.push(path); 224 - } 182 + fn walk(dir: &Path, ext: &str, out: &mut Vec<PathBuf>) -> std::io::Result<()> { 183 + if !dir.is_dir() { 184 + return Ok(()); 185 + } 186 + for entry in fs::read_dir(dir)? { 187 + let entry = entry?; 188 + let path = entry.path(); 189 + if path.is_dir() { 190 + walk(&path, ext, out)?; 191 + } else if path.extension().and_then(|s| s.to_str()) == Some(ext) { 192 + out.push(path); 225 193 } 226 194 } 227 195 Ok(()) 228 196 } 229 197 230 - walk_dir(dir, &mut mlf_files).map_err(|e| format!("Failed to walk directory: {}", e))?; 231 - Ok(mlf_files) 198 + walk(dir, ext, &mut files).map_err(|e| format!("Failed to walk directory: {}", e))?; 199 + Ok(files) 232 200 } 233 201 234 202 #[derive(Debug)] ··· 240 208 failed_lexicons: Vec<(String, String)>, 241 209 } 242 210 243 - /// Compare original JSON with regenerated JSON 244 - fn compare_json_files( 211 + fn compare_regenerated( 245 212 original_dir: &Path, 246 - generated_dir: &Path, 213 + regenerated: &[(PathBuf, serde_json::Value)], 247 214 diffs_dir: &Path, 248 215 ) -> Result<ComparisonStats, String> { 249 - // Create diffs directory 250 216 fs::create_dir_all(diffs_dir) 251 217 .map_err(|e| format!("Failed to create diffs directory: {}", e))?; 218 + 252 219 let mut stats = ComparisonStats { 253 - total: 0, 220 + total: regenerated.len(), 254 221 perfect_matches: 0, 255 222 acceptable_diffs: 0, 256 223 failures: 0, 257 224 failed_lexicons: Vec::new(), 258 225 }; 259 226 260 - // Find all JSON files in original directory 261 - let original_files = find_json_files(original_dir)?; 262 - stats.total = original_files.len(); 263 - 264 - println!(" Comparing {} lexicon files...", stats.total); 265 - 266 - for original_file in original_files { 267 - let relative_path = original_file 268 - .strip_prefix(original_dir) 269 - .map_err(|e| format!("Failed to strip prefix: {}", e))?; 270 - 271 - let generated_file = generated_dir.join(relative_path); 272 - 273 - // Extract NSID from path for reporting 227 + for (relative_path, generated) in regenerated { 274 228 let nsid = relative_path 275 229 .with_extension("") 276 230 .to_str() 277 231 .unwrap() 278 232 .replace(std::path::MAIN_SEPARATOR, "."); 279 233 280 - if !generated_file.exists() { 234 + let original_path = original_dir.join(relative_path); 235 + if !original_path.exists() { 281 236 stats.failures += 1; 282 - stats.failed_lexicons 283 - .push((nsid.clone(), "Generated file not found".to_string())); 284 - eprintln!(" ✗ {}: Generated file not found", nsid); 237 + stats 238 + .failed_lexicons 239 + .push((nsid, format!("Original file not found: {}", original_path.display()))); 285 240 continue; 286 241 } 287 242 288 - // Read and parse JSON files 289 - let original_json = fs::read_to_string(&original_file) 243 + let original_text = fs::read_to_string(&original_path) 290 244 .map_err(|e| format!("Failed to read original: {}", e))?; 291 - let generated_json = fs::read_to_string(&generated_file) 292 - .map_err(|e| format!("Failed to read generated: {}", e))?; 293 - 294 - let original: serde_json::Value = serde_json::from_str(&original_json) 245 + let original: serde_json::Value = serde_json::from_str(&original_text) 295 246 .map_err(|e| format!("Failed to parse original JSON: {}", e))?; 296 - let generated: serde_json::Value = serde_json::from_str(&generated_json) 297 - .map_err(|e| format!("Failed to parse generated JSON: {}", e))?; 247 + 248 + let generated_text = serde_json::to_string_pretty(generated) 249 + .map_err(|e| format!("Failed to serialise generated JSON: {}", e))?; 298 250 299 - // Compare with allowed differences 300 - match compare_lexicon_json(&original, &generated) { 251 + match compare_lexicon_json(&original, generated) { 301 252 ComparisonResult::Perfect => { 302 253 stats.perfect_matches += 1; 303 - println!(" ✓ {} (perfect match)", nsid); 304 254 } 305 255 ComparisonResult::AcceptableDifferences(diffs) => { 306 256 stats.acceptable_diffs += 1; 307 - println!(" ✓ {} (acceptable diffs: {})", nsid, diffs.join(", ")); 308 - 309 - // Write diff file for acceptable differences 310 - write_diff_file(diffs_dir, &nsid, &original_json, &generated_json, "acceptable") 257 + write_diff_file(diffs_dir, &nsid, &original_text, &generated_text, "acceptable") 311 258 .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e)); 259 + println!(" ✓ {} (acceptable: {})", nsid, diffs.join(", ")); 312 260 } 313 261 ComparisonResult::Failure(reason) => { 314 262 stats.failures += 1; 315 263 stats.failed_lexicons.push((nsid.clone(), reason.clone())); 264 + write_diff_file(diffs_dir, &nsid, &original_text, &generated_text, "failure") 265 + .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e)); 316 266 eprintln!(" ✗ {}: {}", nsid, reason); 317 - 318 - // Write diff file for failures 319 - write_diff_file(diffs_dir, &nsid, &original_json, &generated_json, "failure") 320 - .unwrap_or_else(|e| eprintln!("Warning: Failed to write diff: {}", e)); 321 267 } 322 268 } 323 269 } ··· 325 271 Ok(stats) 326 272 } 327 273 328 - /// Find all JSON files recursively 329 - fn find_json_files(dir: &Path) -> Result<Vec<PathBuf>, String> { 330 - let mut json_files = Vec::new(); 331 - 332 - fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> { 333 - if dir.is_dir() { 334 - for entry in fs::read_dir(dir)? { 335 - let entry = entry?; 336 - let path = entry.path(); 337 - if path.is_dir() { 338 - walk_dir(&path, files)?; 339 - } else if path.extension().and_then(|s| s.to_str()) == Some("json") { 340 - files.push(path); 341 - } 342 - } 343 - } 344 - Ok(()) 345 - } 346 - 347 - walk_dir(dir, &mut json_files).map_err(|e| format!("Failed to walk directory: {}", e))?; 348 - Ok(json_files) 349 - } 350 - 351 274 #[derive(Debug)] 352 275 enum ComparisonResult { 353 276 Perfect, ··· 362 285 ) -> ComparisonResult { 363 286 let mut acceptable_diffs = Vec::new(); 364 287 365 - // Strip $type fields (these are often added/removed) 366 288 let original_stripped = strip_dollar_type(original); 367 289 let generated_stripped = strip_dollar_type(generated); 368 290 369 - // Check if they're identical after stripping $type 370 291 if original_stripped == generated_stripped { 371 292 return ComparisonResult::Perfect; 372 293 } 373 294 374 - // Allow $type differences 375 - if has_only_dollar_type_diff(&original_stripped, &generated_stripped) { 376 - acceptable_diffs.push("$type fields".to_string()); 377 - } 378 - 379 - // Check for field ordering differences (same fields, different order) 295 + // The strip above already handled $type-only differences; detect 296 + // field ordering next since our regen emits in declaration order. 380 297 if has_only_ordering_diff(&original_stripped, &generated_stripped) { 381 298 acceptable_diffs.push("field ordering".to_string()); 382 299 return ComparisonResult::AcceptableDifferences(acceptable_diffs); 383 300 } 384 301 385 - // If we have acceptable diffs, return them 386 - if !acceptable_diffs.is_empty() { 387 - return ComparisonResult::AcceptableDifferences(acceptable_diffs); 388 - } 389 - 390 - // Otherwise, it's a failure 391 - ComparisonResult::Failure(format!( 392 - "Structural differences detected" 393 - )) 302 + ComparisonResult::Failure("Structural differences detected".to_string()) 394 303 } 395 304 396 - /// Recursively strip $type fields from JSON 397 305 fn strip_dollar_type(value: &serde_json::Value) -> serde_json::Value { 398 306 match value { 399 307 serde_json::Value::Object(map) => { ··· 412 320 } 413 321 } 414 322 415 - /// Check if the only difference is $type fields 416 - fn has_only_dollar_type_diff(v1: &serde_json::Value, v2: &serde_json::Value) -> bool { 417 - // After stripping $type, they should be equal 418 - v1 == v2 419 - } 420 - 421 - /// Write diff files showing differences between original and generated JSON 422 323 fn write_diff_file( 423 324 diffs_dir: &Path, 424 325 nsid: &str, ··· 426 327 generated_json: &str, 427 328 diff_type: &str, 428 329 ) -> Result<(), String> { 429 - // Create subdirectory based on diff type 430 330 let type_dir = diffs_dir.join(diff_type); 431 331 fs::create_dir_all(&type_dir) 432 332 .map_err(|e| format!("Failed to create diff type directory: {}", e))?; 433 333 434 - // Create base filename from NSID 435 334 let base_filename = nsid.replace('.', "_"); 436 335 437 - // Write original JSON 438 336 let original_path = type_dir.join(format!("{}.original.json", base_filename)); 439 337 fs::write(&original_path, original_json) 440 338 .map_err(|e| format!("Failed to write original JSON: {}", e))?; 441 339 442 - // Write generated JSON 443 340 let generated_path = type_dir.join(format!("{}.generated.json", base_filename)); 444 341 fs::write(&generated_path, generated_json) 445 342 .map_err(|e| format!("Failed to write generated JSON: {}", e))?; 446 343 447 - // Run diff command and save output 448 344 let diff_path = type_dir.join(format!("{}.diff", base_filename)); 449 345 let diff_output = Command::new("diff") 450 346 .arg("-u") ··· 453 349 .output() 454 350 .map_err(|e| format!("Failed to run diff command: {}", e))?; 455 351 456 - // diff returns exit code 1 when files differ, which is expected 457 - // Only error if exit code is 2+ (indicates an error running diff) 352 + // diff exit code 1 just means "files differ" — only error on 2+. 458 353 if diff_output.status.code() == Some(2) { 459 - return Err(format!("diff command error: {}", String::from_utf8_lossy(&diff_output.stderr))); 354 + return Err(format!( 355 + "diff command error: {}", 356 + String::from_utf8_lossy(&diff_output.stderr) 357 + )); 460 358 } 461 359 462 - // Write diff output 463 360 fs::write(&diff_path, &diff_output.stdout) 464 361 .map_err(|e| format!("Failed to write diff output: {}", e))?; 465 362 466 363 Ok(()) 467 364 } 468 365 469 - /// Check if the only difference is field ordering in objects 470 366 fn has_only_ordering_diff(v1: &serde_json::Value, v2: &serde_json::Value) -> bool { 471 367 match (v1, v2) { 472 368 (serde_json::Value::Object(map1), serde_json::Value::Object(map2)) => { 473 - // Check if they have the same keys 474 369 let keys1: HashSet<_> = map1.keys().collect(); 475 370 let keys2: HashSet<_> = map2.keys().collect(); 476 371 ··· 478 373 return false; 479 374 } 480 375 481 - // Check if all values match (recursively) 482 376 for key in keys1 { 483 377 let val1 = &map1[key]; 484 378 let val2 = &map2[key]; ··· 491 385 true 492 386 } 493 387 (serde_json::Value::Array(arr1), serde_json::Value::Array(arr2)) => { 494 - // Arrays must match exactly (order matters) 495 388 if arr1.len() != arr2.len() { 496 389 return false; 497 390 }