this repo has no description
20
fork

Configure Feed

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

release: 0.14.4

+2898 -79
+16
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.14.4] - 2026-04-02 11 + ### Added 12 + - `transmogrify_record` tool to atpmcp MCP server 13 + - `transmogrify_record` function in atproto-lexicon library 14 + - Scenario triggers, parameter examples, and error guidance to atpmcp tool descriptions 15 + - `lenient_optional_format` datetime module and malformed `createdAt` test 16 + - Default values to `alt` and `role` fields in Media type 17 + - Unified `atpdid` CLI tool for DID management (key generate/inspect, resolve, PLC audit/create/update/submit, did:webvh verify/create/update) 18 + - did:webvh v1.0 resolution with full spec compliance 19 + - DiskRepository with `from_car` support 20 + 21 + ### Changed 22 + - Made lexicon main definition optional per AT Protocol spec 23 + - Moved transmogrify and compatibility into atproto-lexicon library 24 + - Replaced deprecated `plugin-types` CSP directive with `media-src` 25 + 10 26 ## [0.14.3] - 2026-03-12 11 27 ### Added 12 28 - Unknown variant to LocationOrRef and EventLocation enums for calendar events
+371 -24
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "ahash" 7 + version = "0.8.12" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 10 + dependencies = [ 11 + "cfg-if", 12 + "once_cell", 13 + "version_check", 14 + "zerocopy", 15 + ] 16 + 17 + [[package]] 6 18 name = "aho-corasick" 7 19 version = "1.1.4" 8 20 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 110 122 111 123 [[package]] 112 124 name = "atpmcp" 113 - version = "0.14.3" 125 + version = "0.14.4" 114 126 dependencies = [ 115 127 "anyhow", 116 128 "async-trait", ··· 132 144 133 145 [[package]] 134 146 name = "atproto-attestation" 135 - version = "0.14.3" 147 + version = "0.14.4" 136 148 dependencies = [ 137 149 "anyhow", 138 150 "async-trait", ··· 158 170 159 171 [[package]] 160 172 name = "atproto-client" 161 - version = "0.14.3" 173 + version = "0.14.4" 162 174 dependencies = [ 163 175 "anyhow", 164 176 "async-trait", ··· 182 194 183 195 [[package]] 184 196 name = "atproto-dasl" 185 - version = "0.14.3" 197 + version = "0.14.4" 186 198 dependencies = [ 187 199 "anyhow", 188 200 "axum", ··· 210 222 211 223 [[package]] 212 224 name = "atproto-extras" 213 - version = "0.14.3" 225 + version = "0.14.4" 214 226 dependencies = [ 215 227 "anyhow", 216 228 "async-trait", ··· 225 237 226 238 [[package]] 227 239 name = "atproto-identity" 228 - version = "0.14.3" 240 + version = "0.14.4" 229 241 dependencies = [ 230 242 "anyhow", 231 243 "async-trait", ··· 258 270 259 271 [[package]] 260 272 name = "atproto-jetstream" 261 - version = "0.14.3" 273 + version = "0.14.4" 262 274 dependencies = [ 263 275 "anyhow", 264 276 "async-trait", ··· 280 292 281 293 [[package]] 282 294 name = "atproto-lexicon" 283 - version = "0.14.3" 295 + version = "0.14.4" 284 296 dependencies = [ 285 297 "anyhow", 286 298 "async-trait", ··· 292 304 "clap", 293 305 "hickory-resolver", 294 306 "indexmap", 307 + "panproto-core", 295 308 "regex", 296 309 "reqwest", 297 310 "serde", ··· 305 318 306 319 [[package]] 307 320 name = "atproto-oauth" 308 - version = "0.14.3" 321 + version = "0.14.4" 309 322 dependencies = [ 310 323 "anyhow", 311 324 "async-trait", ··· 336 349 337 350 [[package]] 338 351 name = "atproto-oauth-aip" 339 - version = "0.14.3" 352 + version = "0.14.4" 340 353 dependencies = [ 341 354 "anyhow", 342 355 "atproto-identity", ··· 351 364 352 365 [[package]] 353 366 name = "atproto-oauth-axum" 354 - version = "0.14.3" 367 + version = "0.14.4" 355 368 dependencies = [ 356 369 "anyhow", 357 370 "async-trait", ··· 380 393 381 394 [[package]] 382 395 name = "atproto-record" 383 - version = "0.14.3" 396 + version = "0.14.4" 384 397 dependencies = [ 385 398 "anyhow", 386 399 "async-trait", ··· 401 414 402 415 [[package]] 403 416 name = "atproto-repo" 404 - version = "0.14.3" 417 + version = "0.14.4" 405 418 dependencies = [ 406 419 "anyhow", 407 420 "async-trait", ··· 424 437 425 438 [[package]] 426 439 name = "atproto-tap" 427 - version = "0.14.3" 440 + version = "0.14.4" 428 441 dependencies = [ 429 442 "atproto-client", 430 443 "atproto-identity", ··· 447 460 448 461 [[package]] 449 462 name = "atproto-xrpcs" 450 - version = "0.14.3" 463 + version = "0.14.4" 451 464 dependencies = [ 452 465 "anyhow", 453 466 "async-trait", ··· 473 486 474 487 [[package]] 475 488 name = "atproto-xrpcs-helloworld" 476 - version = "0.14.3" 489 + version = "0.14.4" 477 490 dependencies = [ 478 491 "anyhow", 479 492 "async-trait", ··· 500 513 501 514 [[package]] 502 515 name = "atpxrpc" 503 - version = "0.14.3" 516 + version = "0.14.4" 504 517 dependencies = [ 505 518 "anyhow", 506 519 "atproto-client", ··· 1236 1249 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 1237 1250 1238 1251 [[package]] 1252 + name = "float-cmp" 1253 + version = "0.10.0" 1254 + source = "registry+https://github.com/rust-lang/crates.io-index" 1255 + checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" 1256 + dependencies = [ 1257 + "num-traits", 1258 + ] 1259 + 1260 + [[package]] 1239 1261 name = "fnv" 1240 1262 version = "1.0.7" 1241 1263 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1356 1378 ] 1357 1379 1358 1380 [[package]] 1381 + name = "getopts" 1382 + version = "0.2.24" 1383 + source = "registry+https://github.com/rust-lang/crates.io-index" 1384 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 1385 + dependencies = [ 1386 + "unicode-width", 1387 + ] 1388 + 1389 + [[package]] 1359 1390 name = "getrandom" 1360 1391 version = "0.2.17" 1361 1392 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1434 1465 "cfg-if", 1435 1466 "crunchy", 1436 1467 "zerocopy", 1468 + ] 1469 + 1470 + [[package]] 1471 + name = "halfbrown" 1472 + version = "0.2.5" 1473 + source = "registry+https://github.com/rust-lang/crates.io-index" 1474 + checksum = "8588661a8607108a5ca69cab034063441a0413a0b041c13618a7dd348021ef6f" 1475 + dependencies = [ 1476 + "hashbrown 0.14.5", 1477 + "serde", 1478 + ] 1479 + 1480 + [[package]] 1481 + name = "hashbrown" 1482 + version = "0.14.5" 1483 + source = "registry+https://github.com/rust-lang/crates.io-index" 1484 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1485 + dependencies = [ 1486 + "ahash", 1487 + "allocator-api2", 1437 1488 ] 1438 1489 1439 1490 [[package]] ··· 1511 1562 "parking_lot", 1512 1563 "rand 0.9.2", 1513 1564 "resolv-conf", 1514 - "smallvec", 1565 + "smallvec 1.15.1", 1515 1566 "thiserror 2.0.18", 1516 1567 "tokio", 1517 1568 "tracing", ··· 1598 1649 "itoa", 1599 1650 "pin-project-lite", 1600 1651 "pin-utils", 1601 - "smallvec", 1652 + "smallvec 1.15.1", 1602 1653 "tokio", 1603 1654 "want", 1604 1655 ] ··· 1681 1732 "icu_normalizer_data", 1682 1733 "icu_properties", 1683 1734 "icu_provider", 1684 - "smallvec", 1735 + "smallvec 1.15.1", 1685 1736 "zerovec", 1686 1737 ] 1687 1738 ··· 1739 1790 checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1740 1791 dependencies = [ 1741 1792 "idna_adapter", 1742 - "smallvec", 1793 + "smallvec 1.15.1", 1743 1794 "utf8_iter", 1744 1795 ] 1745 1796 ··· 1999 2050 "equivalent", 2000 2051 "parking_lot", 2001 2052 "portable-atomic", 2002 - "smallvec", 2053 + "smallvec 1.15.1", 2003 2054 "tagptr", 2004 2055 "uuid", 2005 2056 ] ··· 2105 2156 ] 2106 2157 2107 2158 [[package]] 2159 + name = "panproto-check" 2160 + version = "0.12.0" 2161 + source = "registry+https://github.com/rust-lang/crates.io-index" 2162 + checksum = "8b6c2db113ed303c60f5b33dae9c9e547b2e7e65fe727471ebb239dcc006e535" 2163 + dependencies = [ 2164 + "panproto-gat", 2165 + "panproto-lens", 2166 + "panproto-mig", 2167 + "panproto-schema", 2168 + "rustc-hash", 2169 + "serde", 2170 + "serde_json", 2171 + "thiserror 2.0.18", 2172 + ] 2173 + 2174 + [[package]] 2175 + name = "panproto-core" 2176 + version = "0.12.0" 2177 + source = "registry+https://github.com/rust-lang/crates.io-index" 2178 + checksum = "6daed23d0e62bbff3821494b63500ca6470f7cf75648173964c4ef5e1834173d" 2179 + dependencies = [ 2180 + "panproto-check", 2181 + "panproto-gat", 2182 + "panproto-inst", 2183 + "panproto-io", 2184 + "panproto-lens", 2185 + "panproto-mig", 2186 + "panproto-protocols", 2187 + "panproto-schema", 2188 + "panproto-vcs", 2189 + ] 2190 + 2191 + [[package]] 2192 + name = "panproto-expr" 2193 + version = "0.12.0" 2194 + source = "registry+https://github.com/rust-lang/crates.io-index" 2195 + checksum = "9d66e8a87808ae337302ca9a5e0cc8b8e9af412ac65deaceca76747a4a4e328f" 2196 + dependencies = [ 2197 + "rustc-hash", 2198 + "serde", 2199 + "thiserror 2.0.18", 2200 + ] 2201 + 2202 + [[package]] 2203 + name = "panproto-gat" 2204 + version = "0.12.0" 2205 + source = "registry+https://github.com/rust-lang/crates.io-index" 2206 + checksum = "be91cfcb5ca80ffb5bb7dae4567428f0ea97ab9d6291855da83aac8320744335" 2207 + dependencies = [ 2208 + "panproto-expr", 2209 + "rustc-hash", 2210 + "serde", 2211 + "thiserror 2.0.18", 2212 + ] 2213 + 2214 + [[package]] 2215 + name = "panproto-inst" 2216 + version = "0.12.0" 2217 + source = "registry+https://github.com/rust-lang/crates.io-index" 2218 + checksum = "7c1e5f615998dd4ce35eae756d41288fdf762ec9453251e2c057869a1351ce0e" 2219 + dependencies = [ 2220 + "bumpalo", 2221 + "panproto-expr", 2222 + "panproto-gat", 2223 + "panproto-schema", 2224 + "rustc-hash", 2225 + "serde", 2226 + "serde_json", 2227 + "smallvec 2.0.0-alpha.12", 2228 + "thiserror 2.0.18", 2229 + ] 2230 + 2231 + [[package]] 2232 + name = "panproto-io" 2233 + version = "0.12.0" 2234 + source = "registry+https://github.com/rust-lang/crates.io-index" 2235 + checksum = "d4c8e61e3302d0314ea7987c1cee1c216bfbeb31340e64206203d663c00c7148" 2236 + dependencies = [ 2237 + "bumpalo", 2238 + "memchr", 2239 + "panproto-gat", 2240 + "panproto-inst", 2241 + "panproto-protocols", 2242 + "panproto-schema", 2243 + "pulldown-cmark", 2244 + "quick-xml", 2245 + "rmp-serde", 2246 + "rustc-hash", 2247 + "serde", 2248 + "serde_json", 2249 + "simd-json", 2250 + "smallvec 2.0.0-alpha.12", 2251 + "thiserror 2.0.18", 2252 + "tl", 2253 + ] 2254 + 2255 + [[package]] 2256 + name = "panproto-lens" 2257 + version = "0.12.0" 2258 + source = "registry+https://github.com/rust-lang/crates.io-index" 2259 + checksum = "fad9c8ee8b29d0dc33cf214614ced204dd84f24e8b49f767cf2c4f8ea1716f24" 2260 + dependencies = [ 2261 + "panproto-expr", 2262 + "panproto-gat", 2263 + "panproto-inst", 2264 + "panproto-mig", 2265 + "panproto-schema", 2266 + "rustc-hash", 2267 + "serde", 2268 + "serde_json", 2269 + "smallvec 2.0.0-alpha.12", 2270 + "thiserror 2.0.18", 2271 + ] 2272 + 2273 + [[package]] 2274 + name = "panproto-mig" 2275 + version = "0.12.0" 2276 + source = "registry+https://github.com/rust-lang/crates.io-index" 2277 + checksum = "0303e982245b58a3f4f2145526cfce69a4a5e6ebe95f68d98a78c1b67600e9bc" 2278 + dependencies = [ 2279 + "panproto-expr", 2280 + "panproto-gat", 2281 + "panproto-inst", 2282 + "panproto-schema", 2283 + "rustc-hash", 2284 + "serde", 2285 + "thiserror 2.0.18", 2286 + ] 2287 + 2288 + [[package]] 2289 + name = "panproto-protocols" 2290 + version = "0.12.0" 2291 + source = "registry+https://github.com/rust-lang/crates.io-index" 2292 + checksum = "1c500fb125d81de81da6a3c050adfb4f8145a2088fdd87680ea3db16aaa67919" 2293 + dependencies = [ 2294 + "panproto-gat", 2295 + "panproto-inst", 2296 + "panproto-schema", 2297 + "rustc-hash", 2298 + "serde", 2299 + "serde_json", 2300 + "thiserror 2.0.18", 2301 + ] 2302 + 2303 + [[package]] 2304 + name = "panproto-schema" 2305 + version = "0.12.0" 2306 + source = "registry+https://github.com/rust-lang/crates.io-index" 2307 + checksum = "1590db53d33d9a5b02584b40e2b994750be22154df9d45bed8df531d0d467b51" 2308 + dependencies = [ 2309 + "panproto-expr", 2310 + "panproto-gat", 2311 + "rustc-hash", 2312 + "serde", 2313 + "smallvec 2.0.0-alpha.12", 2314 + "thiserror 2.0.18", 2315 + ] 2316 + 2317 + [[package]] 2318 + name = "panproto-vcs" 2319 + version = "0.12.0" 2320 + source = "registry+https://github.com/rust-lang/crates.io-index" 2321 + checksum = "dc7f8352cdcdd0d08972d00282a5f4bb37f904219e273c3a8e44a1385d1a31dc" 2322 + dependencies = [ 2323 + "blake3", 2324 + "panproto-check", 2325 + "panproto-expr", 2326 + "panproto-gat", 2327 + "panproto-inst", 2328 + "panproto-lens", 2329 + "panproto-mig", 2330 + "panproto-schema", 2331 + "rmp-serde", 2332 + "rustc-hash", 2333 + "serde", 2334 + "serde_json", 2335 + "smallvec 2.0.0-alpha.12", 2336 + "thiserror 2.0.18", 2337 + ] 2338 + 2339 + [[package]] 2108 2340 name = "parking_lot" 2109 2341 version = "0.12.5" 2110 2342 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2123 2355 "cfg-if", 2124 2356 "libc", 2125 2357 "redox_syscall", 2126 - "smallvec", 2358 + "smallvec 1.15.1", 2127 2359 "windows-link", 2128 2360 ] 2129 2361 ··· 2269 2501 "tempfile", 2270 2502 "unarray", 2271 2503 ] 2504 + 2505 + [[package]] 2506 + name = "pulldown-cmark" 2507 + version = "0.12.2" 2508 + source = "registry+https://github.com/rust-lang/crates.io-index" 2509 + checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" 2510 + dependencies = [ 2511 + "bitflags", 2512 + "getopts", 2513 + "memchr", 2514 + "pulldown-cmark-escape", 2515 + "unicase", 2516 + ] 2517 + 2518 + [[package]] 2519 + name = "pulldown-cmark-escape" 2520 + version = "0.11.0" 2521 + source = "registry+https://github.com/rust-lang/crates.io-index" 2522 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 2272 2523 2273 2524 [[package]] 2274 2525 name = "quick-error" ··· 2277 2528 checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2278 2529 2279 2530 [[package]] 2531 + name = "quick-xml" 2532 + version = "0.37.5" 2533 + source = "registry+https://github.com/rust-lang/crates.io-index" 2534 + checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" 2535 + dependencies = [ 2536 + "memchr", 2537 + ] 2538 + 2539 + [[package]] 2280 2540 name = "quinn" 2281 2541 version = "0.11.9" 2282 2542 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2461 2721 ] 2462 2722 2463 2723 [[package]] 2724 + name = "ref-cast" 2725 + version = "1.0.25" 2726 + source = "registry+https://github.com/rust-lang/crates.io-index" 2727 + checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" 2728 + dependencies = [ 2729 + "ref-cast-impl", 2730 + ] 2731 + 2732 + [[package]] 2733 + name = "ref-cast-impl" 2734 + version = "1.0.25" 2735 + source = "registry+https://github.com/rust-lang/crates.io-index" 2736 + checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" 2737 + dependencies = [ 2738 + "proc-macro2", 2739 + "quote", 2740 + "syn", 2741 + ] 2742 + 2743 + [[package]] 2464 2744 name = "regex" 2465 2745 version = "1.12.3" 2466 2746 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2587 2867 "libc", 2588 2868 "untrusted", 2589 2869 "windows-sys 0.52.0", 2870 + ] 2871 + 2872 + [[package]] 2873 + name = "rmp" 2874 + version = "0.8.15" 2875 + source = "registry+https://github.com/rust-lang/crates.io-index" 2876 + checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" 2877 + dependencies = [ 2878 + "num-traits", 2879 + ] 2880 + 2881 + [[package]] 2882 + name = "rmp-serde" 2883 + version = "1.3.1" 2884 + source = "registry+https://github.com/rust-lang/crates.io-index" 2885 + checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" 2886 + dependencies = [ 2887 + "rmp", 2888 + "serde", 2590 2889 ] 2591 2890 2592 2891 [[package]] ··· 2921 3220 ] 2922 3221 2923 3222 [[package]] 3223 + name = "simd-json" 3224 + version = "0.14.3" 3225 + source = "registry+https://github.com/rust-lang/crates.io-index" 3226 + checksum = "aa2bcf6c6e164e81bc7a5d49fc6988b3d515d9e8c07457d7b74ffb9324b9cd40" 3227 + dependencies = [ 3228 + "getrandom 0.2.17", 3229 + "halfbrown", 3230 + "ref-cast", 3231 + "serde", 3232 + "serde_json", 3233 + "simdutf8", 3234 + "value-trait", 3235 + ] 3236 + 3237 + [[package]] 2924 3238 name = "simdutf8" 2925 3239 version = "0.1.5" 2926 3240 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2937 3251 version = "1.15.1" 2938 3252 source = "registry+https://github.com/rust-lang/crates.io-index" 2939 3253 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 3254 + 3255 + [[package]] 3256 + name = "smallvec" 3257 + version = "2.0.0-alpha.12" 3258 + source = "registry+https://github.com/rust-lang/crates.io-index" 3259 + checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" 3260 + dependencies = [ 3261 + "serde_core", 3262 + ] 2940 3263 2941 3264 [[package]] 2942 3265 name = "socket2" ··· 3148 3471 checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 3149 3472 3150 3473 [[package]] 3474 + name = "tl" 3475 + version = "0.7.8" 3476 + source = "registry+https://github.com/rust-lang/crates.io-index" 3477 + checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7" 3478 + 3479 + [[package]] 3151 3480 name = "tokio" 3152 3481 version = "1.50.0" 3153 3482 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3331 3660 "once_cell", 3332 3661 "regex-automata", 3333 3662 "sharded-slab", 3334 - "smallvec", 3663 + "smallvec 1.15.1", 3335 3664 "thread_local", 3336 3665 "tracing", 3337 3666 "tracing-core", ··· 3383 3712 version = "1.12.0" 3384 3713 source = "registry+https://github.com/rust-lang/crates.io-index" 3385 3714 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 3715 + 3716 + [[package]] 3717 + name = "unicode-width" 3718 + version = "0.2.2" 3719 + source = "registry+https://github.com/rust-lang/crates.io-index" 3720 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 3386 3721 3387 3722 [[package]] 3388 3723 name = "unicode-xid" ··· 3448 3783 version = "0.1.1" 3449 3784 source = "registry+https://github.com/rust-lang/crates.io-index" 3450 3785 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3786 + 3787 + [[package]] 3788 + name = "value-trait" 3789 + version = "0.10.1" 3790 + source = "registry+https://github.com/rust-lang/crates.io-index" 3791 + checksum = "9170e001f458781e92711d2ad666110f153e4e50bfd5cbd02db6547625714187" 3792 + dependencies = [ 3793 + "float-cmp", 3794 + "halfbrown", 3795 + "itoa", 3796 + "ryu", 3797 + ] 3451 3798 3452 3799 [[package]] 3453 3800 name = "version_check"
+16 -15
Cargo.toml
··· 33 33 categories = ["command-line-utilities", "web-programming"] 34 34 35 35 [workspace.dependencies] 36 - atproto-attestation = { version = "0.14.3", path = "crates/atproto-attestation" } 37 - atproto-client = { version = "0.14.3", path = "crates/atproto-client" } 38 - atproto-dasl = { version = "0.14.3", path = "crates/atproto-dasl" } 39 - atproto-extras = { version = "0.14.3", path = "crates/atproto-extras" } 40 - atproto-identity = { version = "0.14.3", path = "crates/atproto-identity" } 41 - atproto-jetstream = { version = "0.14.3", path = "crates/atproto-jetstream" } 42 - atproto-lexicon = { version = "0.14.3", path = "crates/atproto-lexicon" } 43 - atproto-oauth = { version = "0.14.3", path = "crates/atproto-oauth" } 44 - atproto-oauth-aip = { version = "0.14.3", path = "crates/atproto-oauth-aip" } 45 - atproto-oauth-axum = { version = "0.14.3", path = "crates/atproto-oauth-axum" } 46 - atproto-record = { version = "0.14.3", path = "crates/atproto-record" } 47 - atproto-repo = { version = "0.14.3", path = "crates/atproto-repo" } 48 - atproto-tap = { version = "0.14.3", path = "crates/atproto-tap" } 49 - atproto-xrpcs = { version = "0.14.3", path = "crates/atproto-xrpcs" } 36 + atproto-attestation = { version = "0.14.4", path = "crates/atproto-attestation" } 37 + atproto-client = { version = "0.14.4", path = "crates/atproto-client" } 38 + atproto-dasl = { version = "0.14.4", path = "crates/atproto-dasl" } 39 + atproto-extras = { version = "0.14.4", path = "crates/atproto-extras" } 40 + atproto-identity = { version = "0.14.4", path = "crates/atproto-identity" } 41 + atproto-jetstream = { version = "0.14.4", path = "crates/atproto-jetstream" } 42 + atproto-lexicon = { version = "0.14.4", path = "crates/atproto-lexicon" } 43 + atproto-oauth = { version = "0.14.4", path = "crates/atproto-oauth" } 44 + atproto-oauth-aip = { version = "0.14.4", path = "crates/atproto-oauth-aip" } 45 + atproto-oauth-axum = { version = "0.14.4", path = "crates/atproto-oauth-axum" } 46 + atproto-record = { version = "0.14.4", path = "crates/atproto-record" } 47 + atproto-repo = { version = "0.14.4", path = "crates/atproto-repo" } 48 + atproto-tap = { version = "0.14.4", path = "crates/atproto-tap" } 49 + atproto-xrpcs = { version = "0.14.4", path = "crates/atproto-xrpcs" } 50 50 51 - atpxrpc = { version = "0.14.3", path = "crates/atpxrpc" } 51 + atpxrpc = { version = "0.14.4", path = "crates/atpxrpc" } 52 52 53 53 bitflags = "2" 54 54 anyhow = "1.0" ··· 79 79 multihash = "0.19" 80 80 p256 = "0.13" 81 81 p384 = "0.13" 82 + panproto-core = "0.12" 82 83 rand = "0.8" 83 84 regex = "1.11" 84 85 reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] }
+1 -1
Dockerfile
··· 73 73 LABEL org.opencontainers.image.description="AT Protocol identity management tools" 74 74 LABEL org.opencontainers.image.authors="Nick Gerakines <nick.gerakines@gmail.com>" 75 75 LABEL org.opencontainers.image.source="https://tangled.org/ngerakines.me/atproto-crates" 76 - LABEL org.opencontainers.image.version="0.14.3" 76 + LABEL org.opencontainers.image.version="0.14.4" 77 77 LABEL org.opencontainers.image.licenses="MIT" 78 78 79 79 # Document available binaries
+11 -11
README.md
··· 55 55 56 56 ```toml 57 57 [dependencies] 58 - atproto-dasl = "0.14.3" 59 - atproto-identity = "0.14.3" 60 - atproto-attestation = "0.14.3" 61 - atproto-record = "0.14.3" 62 - atproto-repo = "0.14.3" 63 - atproto-lexicon = "0.14.3" 64 - atproto-oauth = "0.14.3" 65 - atproto-oauth-aip = "0.14.3" 66 - atproto-client = "0.14.3" 67 - atproto-extras = "0.14.3" 68 - atproto-tap = "0.14.3" 58 + atproto-dasl = "0.14.4" 59 + atproto-identity = "0.14.4" 60 + atproto-attestation = "0.14.4" 61 + atproto-record = "0.14.4" 62 + atproto-repo = "0.14.4" 63 + atproto-lexicon = "0.14.4" 64 + atproto-oauth = "0.14.4" 65 + atproto-oauth-aip = "0.14.4" 66 + atproto-client = "0.14.4" 67 + atproto-extras = "0.14.4" 68 + atproto-tap = "0.14.4" 69 69 # Add others as needed 70 70 ``` 71 71
+2 -2
crates/atpmcp/Cargo.toml
··· 1 1 [package] 2 2 name = "atpmcp" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol MCP server for DAG-CBOR CID generation" 5 5 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 6 documentation = "https://docs.rs/atpmcp" ··· 24 24 atproto-dasl.workspace = true 25 25 atproto-extras = { workspace = true, features = ["hickory-dns"] } 26 26 atproto-identity = { workspace = true, features = ["hickory-dns"] } 27 - atproto-lexicon.workspace = true 27 + atproto-lexicon = { workspace = true, features = ["panproto"] } 28 28 atproto-record.workspace = true 29 29 30 30 dirs.workspace = true
+7
crates/atpmcp/src/errors.rs
··· 70 70 /// Description of the validation failure. 71 71 reason: String, 72 72 }, 73 + 74 + /// Record transmogrification failed. 75 + #[error("error-atpmcp-tool-10 Transmogrification failed: {reason}")] 76 + TransmogrifyFailed { 77 + /// Description of the transmogrification failure. 78 + reason: String, 79 + }, 73 80 }
+103 -1
crates/atpmcp/src/main.rs
··· 23 23 //! - `get_lexicon` — Fetch a lexicon schema record by NSID 24 24 //! - `validate_xrpc` — Validate XRPC parameters against a lexicon schema 25 25 //! - `invoke_xrpc` — Make an XRPC request to an AT Protocol service 26 + //! - `transmogrify_record` — Transform a record from one lexicon schema to another 26 27 27 28 mod errors; 28 29 ··· 36 37 use atproto_client::com::atproto::server::{create_session, refresh_session}; 37 38 use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 38 39 use atproto_identity::url::build_url; 40 + use atproto_lexicon::resolve::DefaultLexiconResolver; 41 + use atproto_lexicon::transmogrify::{TransmogrifyMappings, transmogrify_record}; 39 42 use errors::ToolError; 40 43 use reqwest::header::HeaderMap; 41 44 use serde::{Deserialize, Serialize}; ··· 344 347 }, 345 348 "additionalProperties": false 346 349 } 350 + }, 351 + { 352 + "name": "transmogrify_record", 353 + "description": "Transforms an AT Protocol record from one lexicon schema to another using schema morphism discovery with JSON-level property matching as a fallback. Use this when you need to convert a record between similar but different lexicon types (e.g., converting a post from one app's schema to another). Accepts the source record JSON directly along with source and destination NSIDs, fetches both lexicon schemas, then maps fields using structural morphisms or name matching with optional caller-provided overrides. Returns the transformed record JSON with a morphism quality score (0.0–1.0). Returns an error if the schemas cannot be fetched or if no fields can be matched between source and destination.", 354 + "inputSchema": { 355 + "type": "object", 356 + "properties": { 357 + "record": { 358 + "type": "object", 359 + "description": "The source record JSON object to transform (e.g. {\"$type\": \"app.bsky.feed.post\", \"text\": \"Hello\", \"createdAt\": \"2024-01-01T00:00:00.000Z\"})." 360 + }, 361 + "source": { 362 + "type": "string", 363 + "description": "The source lexicon NSID of the record (e.g. 'app.bsky.feed.post')." 364 + }, 365 + "destination": { 366 + "type": "string", 367 + "description": "The destination lexicon NSID to transform the record into (e.g. 'com.example.feed.post')." 368 + }, 369 + "mappings": { 370 + "type": "object", 371 + "description": "Optional mappings to guide the transformation when automatic field matching is insufficient.", 372 + "properties": { 373 + "field_mappings": { 374 + "type": "object", 375 + "description": "Source field name to destination field name mappings. Supports dot-notation for nested paths (e.g. {\"content.url\": \"embed.external.uri\"}).", 376 + "additionalProperties": { "type": "string" } 377 + }, 378 + "defaults": { 379 + "type": "object", 380 + "description": "Default values for destination fields not matched from the source. Supports dot-notation for nested paths (e.g. {\"embed.$type\": \"app.bsky.embed.external\"})." 381 + } 382 + } 383 + } 384 + }, 385 + "required": ["record", "source", "destination"], 386 + "additionalProperties": false 387 + } 347 388 } 348 389 ] 349 390 }) ··· 390 431 JsonRpcResponse::success(id, handle_invoke_xrpc(arguments, resolver).await) 391 432 } 392 433 "generate_tid" => JsonRpcResponse::success(id, handle_generate_tid(arguments)), 434 + "transmogrify_record" => { 435 + JsonRpcResponse::success(id, handle_transmogrify_record(arguments, resolver).await) 436 + } 393 437 _ => JsonRpcResponse::error(id, METHOD_NOT_FOUND, format!("Unknown tool: {name}")), 394 438 } 395 439 } ··· 1179 1223 } 1180 1224 } 1181 1225 1226 + /// Execute the `transmogrify_record` tool. 1227 + async fn handle_transmogrify_record(arguments: Value, resolver: &InnerIdentityResolver) -> Value { 1228 + let Some(record) = arguments.get("record").cloned() else { 1229 + return tool_error("The 'record' argument must be a JSON object."); 1230 + }; 1231 + if !record.is_object() { 1232 + return tool_error("The 'record' argument must be a JSON object."); 1233 + } 1234 + 1235 + let Some(source) = arguments.get("source").and_then(Value::as_str) else { 1236 + return tool_error("The 'source' argument must be a string."); 1237 + }; 1238 + let Some(destination) = arguments.get("destination").and_then(Value::as_str) else { 1239 + return tool_error("The 'destination' argument must be a string."); 1240 + }; 1241 + 1242 + let mappings: Option<TransmogrifyMappings> = arguments 1243 + .get("mappings") 1244 + .and_then(|v| serde_json::from_value(v.clone()).ok()); 1245 + 1246 + let lexicon_resolver = 1247 + DefaultLexiconResolver::new(resolver.http_client.clone(), resolver.dns_resolver.clone()); 1248 + 1249 + match transmogrify_record( 1250 + &lexicon_resolver, 1251 + source, 1252 + destination, 1253 + &record, 1254 + mappings.as_ref(), 1255 + ) 1256 + .await 1257 + { 1258 + Ok(result) => { 1259 + let result_json = serde_json::to_string_pretty(&result.record) 1260 + .unwrap_or_else(|_| result.record.to_string()); 1261 + 1262 + tracing::info!( 1263 + source = %source, 1264 + destination = %destination, 1265 + quality = %result.quality, 1266 + "transmogrify_record completed" 1267 + ); 1268 + 1269 + tool_success(&format!( 1270 + "Transmogrified record from '{}' to '{}'.\n\nMorphism quality: {:.2}\n\nResult:\n```json\n{}\n```", 1271 + source, destination, result.quality, result_json 1272 + )) 1273 + } 1274 + Err(error) => { 1275 + let tool_err = ToolError::TransmogrifyFailed { 1276 + reason: error.to_string(), 1277 + }; 1278 + tracing::error!(error = %error, "Transmogrification failed."); 1279 + tool_error(&tool_err.to_string()) 1280 + } 1281 + } 1282 + } 1283 + 1182 1284 /// Build a successful tool call result. 1183 1285 fn tool_success(text: &str) -> Value { 1184 1286 serde_json::json!({ ··· 1325 1427 fn test_handle_tools_list() { 1326 1428 let result = handle_tools_list(); 1327 1429 let tools = result["tools"].as_array().unwrap(); 1328 - assert_eq!(tools.len(), 10); 1430 + assert_eq!(tools.len(), 11); 1329 1431 assert_eq!(tools[0]["name"], "create_record_cid"); 1330 1432 assert_eq!(tools[1]["name"], "validate_lexicon_schema"); 1331 1433 assert_eq!(tools[2]["name"], "resolve_handle_to_did");
+1 -1
crates/atproto-attestation/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-attestation" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol attestation utilities for creating and verifying record signatures" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-client/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-client" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "HTTP client for AT Protocol services with OAuth and identity integration" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-dasl/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-dasl" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "DASL (Data-Addressed Structures & Links) implementation for AT Protocol" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-extras/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-extras" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol extras - facet parsing and rich text utilities" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-identity" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol identity management - DID resolution, handle resolution, and cryptographic operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs
··· 487 487 let url = format!("{}/{}/log/audit", plc_url, did); 488 488 489 489 let client = reqwest::Client::builder() 490 - .user_agent("atproto-identity-plc-audit/0.14.3") 490 + .user_agent("atproto-identity-plc-audit/0.14.4") 491 491 .timeout(std::time::Duration::from_secs(30)) 492 492 .build()?; 493 493
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs
··· 594 594 let url = format!("{}/{}/log/audit", plc_url, did); 595 595 596 596 let client = reqwest::Client::builder() 597 - .user_agent("atproto-identity-plc-fork-viz/0.14.3") 597 + .user_agent("atproto-identity-plc-fork-viz/0.14.4") 598 598 .timeout(std::time::Duration::from_secs(30)) 599 599 .build()?; 600 600
+7
crates/atproto-identity/src/traits.rs
··· 26 26 async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 27 27 } 28 28 29 + #[async_trait] 30 + impl<T: DnsResolver + ?Sized> DnsResolver for std::sync::Arc<T> { 31 + async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError> { 32 + (**self).resolve_txt(domain).await 33 + } 34 + } 35 + 29 36 /// Trait for retrieving private keys by identifier. 30 37 #[async_trait] 31 38 /// Trait for resolving key references (e.g., DID verification methods) to [`KeyData`].
+1 -1
crates/atproto-jetstream/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-jetstream" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol Jetstream event consumer library with WebSocket streaming and compression support" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+3 -1
crates/atproto-lexicon/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-lexicon" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol lexicon resolution and validation" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates" ··· 26 26 zeroize = ["dep:zeroize"] 27 27 hickory-dns = ["dep:hickory-resolver"] 28 28 clap = ["dep:clap"] 29 + panproto = ["dep:panproto-core"] 29 30 30 31 [dependencies] 31 32 atproto-dasl.workspace = true ··· 47 48 unicode-segmentation.workspace = true 48 49 zeroize = { workspace = true, optional = true } 49 50 hickory-resolver = { workspace = true, optional = true } 51 + panproto-core = { workspace = true, optional = true } 50 52 51 53 [lints] 52 54 workspace = true
+1 -1
crates/atproto-lexicon/README.md
··· 27 27 28 28 ```toml 29 29 [dependencies] 30 - atproto-lexicon = "0.14.3" 30 + atproto-lexicon = "0.14.4" 31 31 ``` 32 32 33 33 ## Usage
+383
crates/atproto-lexicon/src/compatibility.rs
··· 1 + //! Schema compatibility analysis for AT Protocol lexicon schemas. 2 + //! 3 + //! This module provides functions for checking breaking vs non-breaking changes 4 + //! between lexicon schema versions, generating migration guidance, and analyzing 5 + //! cross-schema compatibility impacts. Requires the `panproto` feature. 6 + //! 7 + //! ## Functions 8 + //! 9 + //! - [`check_compatibility`]: Compare two schemas and produce a compatibility report 10 + //! - [`generate_migration_guidance`]: Generate actionable migration guidance 11 + //! - [`check_cross_compatibility`]: Check if breaking changes affect dependent schemas 12 + 13 + use panproto_core::check::classify::{ 14 + self, BreakingChange as PpBreaking, CompatReport as PpCompatReport, 15 + NonBreakingChange as PpNonBreaking, 16 + }; 17 + use panproto_core::check::diff; 18 + use panproto_core::check::report::report_json; 19 + use panproto_core::protocols::web_document::atproto; 20 + use serde::{Deserialize, Serialize}; 21 + 22 + use crate::errors::CompatibilityError; 23 + 24 + /// Report describing compatibility between two schema versions. 25 + #[derive(Debug, Clone, Serialize, Deserialize)] 26 + pub struct CompatibilityReport { 27 + /// Whether any breaking changes were detected. 28 + pub is_breaking: bool, 29 + /// List of breaking changes found. 30 + pub breaking_changes: Vec<Change>, 31 + /// List of non-breaking changes found. 32 + pub non_breaking_changes: Vec<Change>, 33 + /// Human-readable summary of the changes. 34 + pub summary: String, 35 + /// Raw JSON from panproto's report for detailed/structured output. 36 + pub detail_json: serde_json::Value, 37 + } 38 + 39 + /// A single schema change. 40 + #[derive(Debug, Clone, Serialize, Deserialize)] 41 + pub struct Change { 42 + /// The kind of change (e.g. "RemovedVertex", "AddedEdge"). 43 + pub kind: String, 44 + /// Debug description of the change. 45 + pub description: String, 46 + } 47 + 48 + /// Migration guidance between two schema versions. 49 + #[derive(Debug, Clone, Serialize, Deserialize)] 50 + pub struct MigrationGuidance { 51 + /// Vertices removed from the schema. 52 + pub removed_vertices: Vec<String>, 53 + /// Vertices added to the schema. 54 + pub added_vertices: Vec<String>, 55 + /// Constraint changes (tightened, relaxed, added, removed). 56 + pub constraint_changes: Vec<ConstraintChange>, 57 + /// Additional notes about the migration. 58 + pub notes: Vec<String>, 59 + } 60 + 61 + /// A single constraint change in a schema migration. 62 + #[derive(Debug, Clone, Serialize, Deserialize)] 63 + pub struct ConstraintChange { 64 + /// The kind of constraint change. 65 + pub kind: String, 66 + /// Debug description of the constraint change. 67 + pub description: String, 68 + } 69 + 70 + /// Impact of schema changes on a dependent schema. 71 + #[derive(Debug, Clone, Serialize, Deserialize)] 72 + pub struct CrossCompatibilityImpact { 73 + /// The NSID of the dependent schema. 74 + pub nsid: String, 75 + /// Whether the dependent is affected by the changes. 76 + pub is_affected: bool, 77 + /// The breaking changes that affect this dependent. 78 + pub affected_changes: Vec<Change>, 79 + } 80 + 81 + /// Compare two lexicon schema JSON values and produce a compatibility report. 82 + pub fn check_compatibility( 83 + from_schema: &serde_json::Value, 84 + to_schema: &serde_json::Value, 85 + ) -> Result<CompatibilityReport, CompatibilityError> { 86 + let protocol = atproto::protocol(); 87 + 88 + let old = atproto::parse_lexicon(from_schema) 89 + .map_err(|e| CompatibilityError::ParseFrom(e.to_string()))?; 90 + let new = atproto::parse_lexicon(to_schema) 91 + .map_err(|e| CompatibilityError::ParseTo(e.to_string()))?; 92 + 93 + let schema_diff = diff::diff(&old, &new); 94 + let compat: PpCompatReport = classify::classify(&schema_diff, &protocol); 95 + let detail_json = report_json(&compat); 96 + 97 + let breaking_changes: Vec<Change> = compat 98 + .breaking 99 + .iter() 100 + .map(|bc| Change { 101 + kind: breaking_kind_name(bc), 102 + description: format!("{bc:?}"), 103 + }) 104 + .collect(); 105 + 106 + let non_breaking_changes: Vec<Change> = compat 107 + .non_breaking 108 + .iter() 109 + .map(|nbc| Change { 110 + kind: non_breaking_kind_name(nbc), 111 + description: format!("{nbc:?}"), 112 + }) 113 + .collect(); 114 + 115 + let summary = build_summary(&breaking_changes, &non_breaking_changes, compat.compatible); 116 + 117 + Ok(CompatibilityReport { 118 + is_breaking: !compat.compatible, 119 + breaking_changes, 120 + non_breaking_changes, 121 + summary, 122 + detail_json, 123 + }) 124 + } 125 + 126 + /// Generate migration guidance between two schema versions. 127 + pub fn generate_migration_guidance( 128 + from_schema: &serde_json::Value, 129 + to_schema: &serde_json::Value, 130 + ) -> Result<MigrationGuidance, CompatibilityError> { 131 + let protocol = atproto::protocol(); 132 + 133 + let old = atproto::parse_lexicon(from_schema) 134 + .map_err(|e| CompatibilityError::ParseFrom(e.to_string()))?; 135 + let new = atproto::parse_lexicon(to_schema) 136 + .map_err(|e| CompatibilityError::ParseTo(e.to_string()))?; 137 + 138 + let schema_diff = diff::diff(&old, &new); 139 + let compat = classify::classify(&schema_diff, &protocol); 140 + 141 + let mut removed_vertices = Vec::new(); 142 + let mut added_vertices = Vec::new(); 143 + let mut constraint_changes = Vec::new(); 144 + let mut notes = Vec::new(); 145 + 146 + for bc in &compat.breaking { 147 + match bc { 148 + PpBreaking::RemovedVertex { .. } => { 149 + removed_vertices.push(format!("{bc:?}")); 150 + } 151 + PpBreaking::ConstraintTightened { .. } | PpBreaking::ConstraintAdded { .. } => { 152 + constraint_changes.push(ConstraintChange { 153 + kind: breaking_kind_name(bc), 154 + description: format!("{bc:?}"), 155 + }); 156 + } 157 + _ => { 158 + notes.push(format!("Breaking: {bc:?}")); 159 + } 160 + } 161 + } 162 + 163 + for nbc in &compat.non_breaking { 164 + match nbc { 165 + PpNonBreaking::AddedVertex { .. } => { 166 + added_vertices.push(format!("{nbc:?}")); 167 + } 168 + PpNonBreaking::ConstraintRelaxed { .. } | PpNonBreaking::ConstraintRemoved { .. } => { 169 + constraint_changes.push(ConstraintChange { 170 + kind: non_breaking_kind_name(nbc), 171 + description: format!("{nbc:?}"), 172 + }); 173 + } 174 + _ => { 175 + notes.push(format!("Non-breaking: {nbc:?}")); 176 + } 177 + } 178 + } 179 + 180 + Ok(MigrationGuidance { 181 + removed_vertices, 182 + added_vertices, 183 + constraint_changes, 184 + notes, 185 + }) 186 + } 187 + 188 + /// Check whether breaking changes in a schema affect its dependents. 189 + pub fn check_cross_compatibility( 190 + from_schema: &serde_json::Value, 191 + to_schema: &serde_json::Value, 192 + changed_nsid: &str, 193 + dependent_schemas: &[(String, serde_json::Value)], 194 + ) -> Result<Vec<CrossCompatibilityImpact>, CompatibilityError> { 195 + let report = check_compatibility(from_schema, to_schema)?; 196 + 197 + if !report.is_breaking { 198 + return Ok(dependent_schemas 199 + .iter() 200 + .map(|(nsid, _)| CrossCompatibilityImpact { 201 + nsid: nsid.clone(), 202 + is_affected: false, 203 + affected_changes: Vec::new(), 204 + }) 205 + .collect()); 206 + } 207 + 208 + let mut impacts = Vec::new(); 209 + 210 + for (dep_nsid, dep_schema) in dependent_schemas { 211 + let dep_json = serde_json::to_string(dep_schema).unwrap_or_default(); 212 + let references_changed = dep_json.contains(changed_nsid); 213 + 214 + if references_changed { 215 + impacts.push(CrossCompatibilityImpact { 216 + nsid: dep_nsid.clone(), 217 + is_affected: true, 218 + affected_changes: report.breaking_changes.clone(), 219 + }); 220 + } else { 221 + impacts.push(CrossCompatibilityImpact { 222 + nsid: dep_nsid.clone(), 223 + is_affected: false, 224 + affected_changes: Vec::new(), 225 + }); 226 + } 227 + } 228 + 229 + Ok(impacts) 230 + } 231 + 232 + fn breaking_kind_name(bc: &PpBreaking) -> String { 233 + match bc { 234 + PpBreaking::RemovedVertex { .. } => "RemovedVertex".to_string(), 235 + PpBreaking::RemovedEdge { .. } => "RemovedEdge".to_string(), 236 + PpBreaking::RemovedVariant { .. } => "RemovedVariant".to_string(), 237 + PpBreaking::KindChanged { .. } => "KindChanged".to_string(), 238 + PpBreaking::ConstraintTightened { .. } => "ConstraintTightened".to_string(), 239 + PpBreaking::ConstraintAdded { .. } => "ConstraintAdded".to_string(), 240 + PpBreaking::OrderToUnordered { .. } => "OrderToUnordered".to_string(), 241 + PpBreaking::RecursionBroken { .. } => "RecursionBroken".to_string(), 242 + PpBreaking::LinearityTightened { .. } => "LinearityTightened".to_string(), 243 + _ => "Unknown".to_string(), 244 + } 245 + } 246 + 247 + fn non_breaking_kind_name(nbc: &PpNonBreaking) -> String { 248 + match nbc { 249 + PpNonBreaking::AddedVertex { .. } => "AddedVertex".to_string(), 250 + PpNonBreaking::AddedEdge { .. } => "AddedEdge".to_string(), 251 + PpNonBreaking::ConstraintRelaxed { .. } => "ConstraintRelaxed".to_string(), 252 + PpNonBreaking::ConstraintRemoved { .. } => "ConstraintRemoved".to_string(), 253 + PpNonBreaking::RemovedEdge { .. } => "RemovedEdge".to_string(), 254 + _ => "Unknown".to_string(), 255 + } 256 + } 257 + 258 + fn build_summary(breaking: &[Change], non_breaking: &[Change], compatible: bool) -> String { 259 + if compatible { 260 + if non_breaking.is_empty() { 261 + "No changes detected.".to_string() 262 + } else { 263 + format!( 264 + "Backward compatible. {} non-breaking change{}.", 265 + non_breaking.len(), 266 + if non_breaking.len() == 1 { "" } else { "s" } 267 + ) 268 + } 269 + } else { 270 + format!( 271 + "{} breaking change{}, {} non-breaking change{}.", 272 + breaking.len(), 273 + if breaking.len() == 1 { "" } else { "s" }, 274 + non_breaking.len(), 275 + if non_breaking.len() == 1 { "" } else { "s" } 276 + ) 277 + } 278 + } 279 + 280 + #[cfg(test)] 281 + mod tests { 282 + use super::*; 283 + 284 + fn sample_record_schema() -> serde_json::Value { 285 + serde_json::json!({ 286 + "lexicon": 1, 287 + "id": "com.example.test", 288 + "defs": { 289 + "main": { 290 + "type": "record", 291 + "key": "tid", 292 + "record": { 293 + "type": "object", 294 + "required": ["text", "createdAt"], 295 + "properties": { 296 + "text": { "type": "string", "maxLength": 300 }, 297 + "createdAt": { "type": "string", "format": "datetime" } 298 + } 299 + } 300 + } 301 + } 302 + }) 303 + } 304 + 305 + fn sample_record_schema_added_field() -> serde_json::Value { 306 + serde_json::json!({ 307 + "lexicon": 1, 308 + "id": "com.example.test", 309 + "defs": { 310 + "main": { 311 + "type": "record", 312 + "key": "tid", 313 + "record": { 314 + "type": "object", 315 + "required": ["text", "createdAt"], 316 + "properties": { 317 + "text": { "type": "string", "maxLength": 300 }, 318 + "createdAt": { "type": "string", "format": "datetime" }, 319 + "lang": { "type": "string", "format": "language" } 320 + } 321 + } 322 + } 323 + } 324 + }) 325 + } 326 + 327 + fn sample_record_schema_removed_field() -> serde_json::Value { 328 + serde_json::json!({ 329 + "lexicon": 1, 330 + "id": "com.example.test", 331 + "defs": { 332 + "main": { 333 + "type": "record", 334 + "key": "tid", 335 + "record": { 336 + "type": "object", 337 + "required": ["text"], 338 + "properties": { 339 + "text": { "type": "string", "maxLength": 300 } 340 + } 341 + } 342 + } 343 + } 344 + }) 345 + } 346 + 347 + #[test] 348 + fn test_identical_schemas_are_compatible() { 349 + let schema = sample_record_schema(); 350 + let report = check_compatibility(&schema, &schema).unwrap(); 351 + assert!(!report.is_breaking); 352 + assert!(report.breaking_changes.is_empty()); 353 + } 354 + 355 + #[test] 356 + fn test_added_optional_field_is_non_breaking() { 357 + let from = sample_record_schema(); 358 + let to = sample_record_schema_added_field(); 359 + let report = check_compatibility(&from, &to).unwrap(); 360 + assert!(!report.is_breaking); 361 + assert!(!report.non_breaking_changes.is_empty()); 362 + } 363 + 364 + #[test] 365 + fn test_removed_required_field_is_breaking() { 366 + let from = sample_record_schema(); 367 + let to = sample_record_schema_removed_field(); 368 + let report = check_compatibility(&from, &to).unwrap(); 369 + assert!(report.is_breaking); 370 + assert!(!report.breaking_changes.is_empty()); 371 + } 372 + 373 + #[test] 374 + fn test_migration_guidance_for_removed_field() { 375 + let from = sample_record_schema(); 376 + let to = sample_record_schema_removed_field(); 377 + let guidance = generate_migration_guidance(&from, &to).unwrap(); 378 + assert!( 379 + !guidance.removed_vertices.is_empty() || !guidance.notes.is_empty(), 380 + "migration guidance should note removed elements" 381 + ); 382 + } 383 + }
+57
crates/atproto-lexicon/src/errors.rs
··· 9 9 //! - **`LexiconResolveError`** (lexicon-resolve-1 to lexicon-resolve-6): Errors during lexicon resolution 10 10 //! - **`LexiconValidationError`** (lexicon-validation-1 to lexicon-validation-8): Validation errors for NSIDs and schemas 11 11 //! - **`LexiconSchemaError`** (lexicon-schema-1 to lexicon-schema-4): Schema parsing and structure errors 12 + //! - **`TransmogrifyError`** (lexicon-transmogrify-1 to lexicon-transmogrify-6): Record transmogrification errors 13 + //! - **`CompatibilityError`** (lexicon-compat-1 to lexicon-compat-2): Schema compatibility analysis errors 12 14 //! 13 15 //! ## Error Format 14 16 //! ··· 146 148 /// Failed to resolve any lexicons during recursive resolution. 147 149 #[error("error-atproto-lexicon-recursive-1 Failed to resolve any lexicons")] 148 150 NoLexiconsResolved, 151 + } 152 + 153 + /// Errors that can occur during record transmogrification. 154 + #[derive(Debug, Error)] 155 + pub enum TransmogrifyError { 156 + /// Source schema parsing failed. 157 + #[error("error-atproto-lexicon-transmogrify-1 Failed to parse source schema: {0}")] 158 + ParseFrom(String), 159 + 160 + /// Destination schema parsing failed. 161 + #[error("error-atproto-lexicon-transmogrify-2 Failed to parse destination schema: {0}")] 162 + ParseTo(String), 163 + 164 + /// No morphism found between source and destination schemas. 165 + #[error( 166 + "error-atproto-lexicon-transmogrify-3 No morphism found between source and destination schemas" 167 + )] 168 + NoMorphismFound, 169 + 170 + /// Failed to compile migration between schemas. 171 + #[error("error-atproto-lexicon-transmogrify-4 Failed to compile migration: {0}")] 172 + CompileFailed(String), 173 + 174 + /// Failed to parse record against source schema. 175 + #[error("error-atproto-lexicon-transmogrify-5 Failed to parse record: {0}")] 176 + ParseRecord(String), 177 + 178 + /// Failed to lift record to destination schema. 179 + #[error( 180 + "error-atproto-lexicon-transmogrify-6 Failed to lift record to destination schema: {0}" 181 + )] 182 + LiftFailed(String), 183 + 184 + /// Failed to resolve a lexicon schema during transmogrification. 185 + #[error( 186 + "error-atproto-lexicon-transmogrify-7 Schema resolution failed for '{nsid}': {details}" 187 + )] 188 + SchemaResolveFailed { 189 + /// The NSID that failed to resolve. 190 + nsid: String, 191 + /// Details about the failure. 192 + details: String, 193 + }, 194 + } 195 + 196 + /// Errors that can occur during schema compatibility analysis. 197 + #[derive(Debug, Error)] 198 + pub enum CompatibilityError { 199 + /// Failed to parse the source ('from') schema. 200 + #[error("error-atproto-lexicon-compat-1 Failed to parse source schema: {0}")] 201 + ParseFrom(String), 202 + 203 + /// Failed to parse the destination ('to') schema. 204 + #[error("error-atproto-lexicon-compat-2 Failed to parse destination schema: {0}")] 205 + ParseTo(String), 149 206 } 150 207 151 208 // Re-export the validation error for backwards compatibility during migration
+6
crates/atproto-lexicon/src/lib.rs
··· 20 20 //! - [`errors`]: Structured error types for all lexicon operations 21 21 //! - [`resolve`]: Core lexicon resolution implementation 22 22 //! - [`resolve_recursive`]: Recursive resolution with dependency tracking 23 + //! - [`transmogrify`]: Record transformation between lexicon schemas 24 + //! - [`compatibility`]: Schema compatibility analysis (requires `panproto` feature) 23 25 //! - [`validation`]: NSID validation, parsing, and helper functions 24 26 //! 25 27 //! ## Example Usage ··· 47 49 pub mod errors; 48 50 pub mod resolve; 49 51 pub mod resolve_recursive; 52 + pub mod transmogrify; 50 53 pub mod validation; 54 + 55 + #[cfg(feature = "panproto")] 56 + pub mod compatibility;
+1860
crates/atproto-lexicon/src/transmogrify.rs
··· 1 + //! Record transmogrification between AT Protocol lexicon schemas. 2 + //! 3 + //! This module provides functionality for transforming records from one lexicon 4 + //! schema to another. It supports multiple transformation strategies: 5 + //! 6 + //! - **Panproto morphism discovery** (requires `panproto` feature): Uses formal 7 + //! category-theoretic morphisms to find optimal schema mappings. 8 + //! - **JSON-level fallback**: Uses property-name intersection, explicit field 9 + //! mappings, and default values for pragmatic transformation. 10 + //! 11 + //! ## Example 12 + //! 13 + //! ```no_run 14 + //! use serde_json::json; 15 + //! use atproto_lexicon::transmogrify::{transmogrify, TransmogrifyMappings}; 16 + //! 17 + //! let source_schema = json!({ 18 + //! "lexicon": 1, 19 + //! "id": "com.example.source", 20 + //! "defs": { "main": { "type": "record", "key": "tid", "record": { 21 + //! "type": "object", 22 + //! "properties": { "text": { "type": "string" } } 23 + //! }}} 24 + //! }); 25 + //! let dest_schema = json!({ 26 + //! "lexicon": 1, 27 + //! "id": "com.example.dest", 28 + //! "defs": { "main": { "type": "record", "key": "tid", "record": { 29 + //! "type": "object", 30 + //! "properties": { "text": { "type": "string" }, "title": { "type": "string" } } 31 + //! }}} 32 + //! }); 33 + //! let record = json!({ "text": "hello" }); 34 + //! 35 + //! let result = transmogrify(&source_schema, &dest_schema, &record, None).unwrap(); 36 + //! assert_eq!(result.record["text"], "hello"); 37 + //! ``` 38 + 39 + use std::collections::{HashMap, HashSet}; 40 + 41 + use serde::{Deserialize, Serialize}; 42 + use serde_json::Value; 43 + 44 + use crate::errors::TransmogrifyError; 45 + use crate::resolve::LexiconResolver; 46 + 47 + /// Mappings to guide transmogrification: field renames and default values. 48 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 49 + pub struct TransmogrifyMappings { 50 + /// Field renames: source field name to destination field name. 51 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] 52 + pub field_mappings: HashMap<String, String>, 53 + /// Default values to inject into the destination record. 54 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] 55 + pub defaults: HashMap<String, Value>, 56 + } 57 + 58 + impl TransmogrifyMappings { 59 + /// Returns true if both field_mappings and defaults are empty. 60 + pub fn is_empty(&self) -> bool { 61 + self.field_mappings.is_empty() && self.defaults.is_empty() 62 + } 63 + 64 + /// Returns the field_mappings as a reference, or None if empty. 65 + pub fn field_mappings_ref(&self) -> Option<&HashMap<String, String>> { 66 + if self.field_mappings.is_empty() { 67 + None 68 + } else { 69 + Some(&self.field_mappings) 70 + } 71 + } 72 + } 73 + 74 + /// Result of a transmogrification operation. 75 + #[derive(Debug, Clone, Serialize, Deserialize)] 76 + pub struct TransmogrifyResult { 77 + /// The transformed record JSON. 78 + pub record: Value, 79 + /// Quality score of the transformation (0.0–1.0). 80 + pub quality: f64, 81 + } 82 + 83 + /// Transmogrify a record from one lexicon schema to another. 84 + /// 85 + /// Convenience wrapper over [`transmogrify_with_refs`] that passes `None` 86 + /// for `resolved_refs`. Use [`transmogrify_with_refs`] when external schema 87 + /// references need to be resolved for nested object mapping. 88 + pub fn transmogrify( 89 + from_schema_json: &Value, 90 + to_schema_json: &Value, 91 + record_value: &Value, 92 + mappings: Option<&TransmogrifyMappings>, 93 + ) -> Result<TransmogrifyResult, TransmogrifyError> { 94 + transmogrify_with_refs( 95 + from_schema_json, 96 + to_schema_json, 97 + record_value, 98 + mappings, 99 + None, 100 + ) 101 + } 102 + 103 + /// Resolve schemas and transmogrify a record from one lexicon schema to another. 104 + /// 105 + /// This is the high-level entry point that handles the full pipeline: 106 + /// 1. Resolve source and destination schemas via the provided resolver 107 + /// 2. Extract and resolve external schema references 108 + /// 3. Enrich both schemas with resolved references 109 + /// 4. Run transmogrification 110 + /// 111 + /// Use this when you have NSIDs and a resolver rather than pre-fetched schemas. 112 + pub async fn transmogrify_record( 113 + resolver: &dyn LexiconResolver, 114 + source_nsid: &str, 115 + destination_nsid: &str, 116 + record: &Value, 117 + mappings: Option<&TransmogrifyMappings>, 118 + ) -> Result<TransmogrifyResult, TransmogrifyError> { 119 + let source_schema = resolver.resolve(source_nsid).await.map_err(|e| { 120 + TransmogrifyError::SchemaResolveFailed { 121 + nsid: source_nsid.to_string(), 122 + details: e.to_string(), 123 + } 124 + })?; 125 + 126 + let dest_schema = resolver.resolve(destination_nsid).await.map_err(|e| { 127 + TransmogrifyError::SchemaResolveFailed { 128 + nsid: destination_nsid.to_string(), 129 + details: e.to_string(), 130 + } 131 + })?; 132 + 133 + let mut all_refs = extract_schema_refs(&source_schema); 134 + all_refs.extend(extract_schema_refs(&dest_schema)); 135 + 136 + let mut resolved_refs = HashMap::new(); 137 + for ref_nsid in &all_refs { 138 + let base_nsid = ref_nsid.split('#').next().unwrap_or(ref_nsid); 139 + if resolved_refs.contains_key(base_nsid) { 140 + continue; 141 + } 142 + match resolver.resolve(base_nsid).await { 143 + Ok(schema) => { 144 + resolved_refs.insert(base_nsid.to_string(), schema); 145 + } 146 + Err(error) => { 147 + tracing::debug!(error = ?error, nsid = %base_nsid, "Failed to resolve external ref schema, skipping."); 148 + } 149 + } 150 + } 151 + 152 + let enriched_source = enrich_schema_with_refs(&source_schema, &resolved_refs); 153 + let enriched_dest = enrich_schema_with_refs(&dest_schema, &resolved_refs); 154 + 155 + transmogrify_with_refs( 156 + &enriched_source, 157 + &enriched_dest, 158 + record, 159 + mappings, 160 + Some(&resolved_refs), 161 + ) 162 + } 163 + 164 + /// Transmogrify a record, with access to resolved external schemas for 165 + /// recursive nested-object mapping. 166 + /// 167 + /// Uses a multi-strategy approach: 168 + /// 1. Try strict morphism discovery (best quality, requires `panproto` feature) 169 + /// 2. Fall back to overlap-based partial migration with left Kan extension 170 + /// 3. Property-name intersection at the JSON level, augmented by caller-provided 171 + /// field mappings and default values 172 + pub fn transmogrify_with_refs( 173 + from_schema_json: &Value, 174 + to_schema_json: &Value, 175 + record_value: &Value, 176 + mappings: Option<&TransmogrifyMappings>, 177 + resolved_refs: Option<&HashMap<String, Value>>, 178 + ) -> Result<TransmogrifyResult, TransmogrifyError> { 179 + #[cfg(feature = "panproto")] 180 + { 181 + use panproto_core::inst; 182 + use panproto_core::mig; 183 + use panproto_core::mig::hom_search::{SearchOptions, morphism_to_migration}; 184 + use panproto_core::protocols::web_document::atproto; 185 + 186 + let from_schema = atproto::parse_lexicon(from_schema_json) 187 + .map_err(|e| TransmogrifyError::ParseFrom(e.to_string()))?; 188 + let to_schema = atproto::parse_lexicon(to_schema_json) 189 + .map_err(|e| TransmogrifyError::ParseTo(e.to_string()))?; 190 + 191 + // When explicit field mappings are provided, use the JSON-level strategy directly. 192 + if mappings.is_some_and(|m| !m.field_mappings.is_empty()) { 193 + return transmogrify_json( 194 + record_value, 195 + from_schema_json, 196 + to_schema_json, 197 + mappings, 198 + resolved_refs, 199 + ); 200 + } 201 + 202 + let parsed_instance = find_record_body_vertex(&from_schema) 203 + .and_then(|from_root| inst::parse_json(&from_schema, &from_root, record_value).ok()); 204 + 205 + if let Some(instance) = &parsed_instance { 206 + // Strategy 1: Strict morphism discovery 207 + let search_opts = SearchOptions { 208 + max_results: 1, 209 + ..SearchOptions::default() 210 + }; 211 + let morphisms = mig::find_morphisms(&from_schema, &to_schema, &search_opts); 212 + if let Some(morphism) = morphisms.into_iter().next() { 213 + let quality = morphism.quality; 214 + let migration = morphism_to_migration(&morphism); 215 + if let Ok(compiled) = mig::compile(&from_schema, &to_schema, &migration) 216 + && let Ok(transformed) = 217 + mig::lift_wtype(&compiled, &from_schema, &to_schema, instance) 218 + { 219 + let output = inst::to_json(&to_schema, &transformed); 220 + return Ok(TransmogrifyResult { 221 + record: output, 222 + quality, 223 + }); 224 + } 225 + } 226 + 227 + // Strategy 2: Overlap-based partial migration with left Kan extension 228 + let overlap = mig::discover_overlap(&from_schema, &to_schema); 229 + if !overlap.vertex_pairs.is_empty() { 230 + let mut migration = mig::Migration::empty(); 231 + migration.vertex_map = overlap 232 + .vertex_pairs 233 + .iter() 234 + .map(|(l, r)| (l.clone(), r.clone())) 235 + .collect(); 236 + migration.edge_map = overlap 237 + .edge_pairs 238 + .iter() 239 + .map(|(l, r)| (l.clone(), r.clone())) 240 + .collect(); 241 + 242 + if let Ok(compiled) = mig::compile(&from_schema, &to_schema, &migration) 243 + && let Ok(transformed) = mig::lift_wtype_sigma(&compiled, &to_schema, instance) 244 + { 245 + let output = inst::to_json(&to_schema, &transformed); 246 + let target_vertex_count = to_schema.vertices.len().max(1); 247 + let quality = overlap.vertex_pairs.len() as f64 / target_vertex_count as f64; 248 + return Ok(TransmogrifyResult { 249 + record: output, 250 + quality, 251 + }); 252 + } 253 + } 254 + } 255 + } 256 + 257 + #[cfg(not(feature = "panproto"))] 258 + { 259 + // When explicit field mappings are provided, use the JSON-level strategy directly. 260 + if mappings.is_some_and(|m| !m.field_mappings.is_empty()) { 261 + return transmogrify_json( 262 + record_value, 263 + from_schema_json, 264 + to_schema_json, 265 + mappings, 266 + resolved_refs, 267 + ); 268 + } 269 + } 270 + 271 + // Strategy 3 (or only strategy without panproto): JSON-level fallback 272 + transmogrify_json( 273 + record_value, 274 + from_schema_json, 275 + to_schema_json, 276 + mappings, 277 + resolved_refs, 278 + ) 279 + } 280 + 281 + /// JSON-level transmogrification using property-name intersection, explicit 282 + /// field mappings, and default values. 283 + pub fn transmogrify_json( 284 + record_value: &Value, 285 + from_schema_json: &Value, 286 + to_schema_json: &Value, 287 + mappings: Option<&TransmogrifyMappings>, 288 + resolved_refs: Option<&HashMap<String, Value>>, 289 + ) -> Result<TransmogrifyResult, TransmogrifyError> { 290 + let dest_properties = extract_schema_properties(to_schema_json); 291 + if dest_properties.is_empty() { 292 + return Err(TransmogrifyError::NoMorphismFound); 293 + } 294 + 295 + let reverse_map: HashMap<&str, &str> = mappings 296 + .map(|m| { 297 + m.field_mappings 298 + .iter() 299 + .filter(|(_, dst)| !dst.contains('.')) 300 + .map(|(src, dst)| (dst.as_str(), src.as_str())) 301 + .collect() 302 + }) 303 + .unwrap_or_default(); 304 + 305 + let defaults = mappings.map(|m| &m.defaults); 306 + 307 + let source_obj = record_value 308 + .as_object() 309 + .ok_or_else(|| TransmogrifyError::ParseRecord("record is not a JSON object".to_string()))?; 310 + 311 + let mut output = serde_json::Map::new(); 312 + let mut matched = 0usize; 313 + 314 + // Phase 1: Match top-level destination properties 315 + for dest_prop in &dest_properties { 316 + if let Some(&src_field) = reverse_map.get(dest_prop.as_str()) 317 + && let Some(value) = resolve_dotted_path(record_value, src_field) 318 + { 319 + output.insert(dest_prop.clone(), value.clone()); 320 + matched += 1; 321 + continue; 322 + } 323 + if let Some(value) = source_obj.get(dest_prop) { 324 + output.insert(dest_prop.clone(), value.clone()); 325 + matched += 1; 326 + continue; 327 + } 328 + if let Some(default_value) = defaults.and_then(|d| d.get(dest_prop)) 329 + && !dest_prop.contains('.') 330 + { 331 + output.insert(dest_prop.clone(), default_value.clone()); 332 + matched += 1; 333 + } 334 + } 335 + 336 + // Phase 2: Auto-map nested typed objects to unmatched destination union/ref fields 337 + if let Some(refs) = resolved_refs { 338 + let nested_sources: Vec<(&str, &Value)> = source_obj 339 + .iter() 340 + .filter(|(key, val)| { 341 + !output.contains_key(key.as_str()) 342 + && val.is_object() 343 + && val.get("$type").and_then(|t| t.as_str()).is_some() 344 + }) 345 + .map(|(k, v)| (k.as_str(), v)) 346 + .collect(); 347 + 348 + if !nested_sources.is_empty() { 349 + let unmatched_dest: Vec<&str> = dest_properties 350 + .iter() 351 + .filter(|prop| !output.contains_key(prop.as_str())) 352 + .map(|s| s.as_str()) 353 + .collect(); 354 + 355 + for dest_prop in unmatched_dest { 356 + let prop_def = get_property_schema_def(to_schema_json, dest_prop); 357 + let candidate_nsids = prop_def 358 + .as_ref() 359 + .map(get_union_or_ref_candidates) 360 + .unwrap_or_default(); 361 + 362 + if candidate_nsids.is_empty() { 363 + continue; 364 + } 365 + 366 + let mut best: Option<(Value, f64, String)> = None; 367 + 368 + for (_, src_val) in &nested_sources { 369 + let src_type = src_val.get("$type").unwrap().as_str().unwrap(); 370 + let src_nested_schema = 371 + resolve_nested_type_schema(src_type, from_schema_json, refs); 372 + 373 + let Some(src_schema) = src_nested_schema else { 374 + continue; 375 + }; 376 + 377 + for candidate_nsid in &candidate_nsids { 378 + let dest_nested_schema = 379 + resolve_nested_type_schema(candidate_nsid, to_schema_json, refs); 380 + 381 + let Some(dest_schema) = dest_nested_schema else { 382 + continue; 383 + }; 384 + 385 + let panproto_result = 386 + transmogrify(&src_schema, &dest_schema, src_val, None).ok(); 387 + 388 + let deep_result = 389 + deep_match_nested(src_val, &dest_schema).and_then(|record| { 390 + let dest_leaf_count = count_dest_schema_leaves(&dest_schema); 391 + let matched_count = 392 + record.as_object().map(count_leaf_values).unwrap_or(0); 393 + let quality = matched_count as f64 / dest_leaf_count.max(1) as f64; 394 + if quality >= 0.75 { 395 + Some(TransmogrifyResult { record, quality }) 396 + } else { 397 + None 398 + } 399 + }); 400 + 401 + let result_opt = match (panproto_result, deep_result) { 402 + (Some(a), Some(b)) => Some(if b.quality >= a.quality { b } else { a }), 403 + (Some(a), None) => Some(a), 404 + (None, Some(b)) => Some(b), 405 + (None, None) => None, 406 + }; 407 + 408 + if let Some(result) = result_opt 409 + && best.as_ref().is_none_or(|(_, q, _)| result.quality > *q) 410 + { 411 + let mut nested_record = result.record; 412 + if let Some(obj) = nested_record.as_object_mut() { 413 + obj.insert( 414 + "$type".to_string(), 415 + Value::String(candidate_nsid.clone()), 416 + ); 417 + } 418 + best = Some((nested_record, result.quality, candidate_nsid.clone())); 419 + } 420 + } 421 + } 422 + 423 + if let Some((nested_record, _, _)) = best { 424 + output.insert(dest_prop.to_string(), nested_record); 425 + matched += 1; 426 + } 427 + } 428 + } 429 + } 430 + 431 + // Phase 3: Apply dotted-path field mappings 432 + if let Some(m) = mappings { 433 + for (src_path, dst_path) in &m.field_mappings { 434 + if !dst_path.contains('.') { 435 + continue; 436 + } 437 + if let Some(value) = resolve_dotted_path(record_value, src_path) { 438 + set_nested_path(&mut output, dst_path, value.clone()); 439 + matched += 1; 440 + } 441 + } 442 + } 443 + 444 + // Phase 4: Apply dotted-path defaults 445 + if let Some(defaults_map) = defaults { 446 + for (path, value) in defaults_map { 447 + if !path.contains('.') { 448 + continue; 449 + } 450 + if resolve_dotted_path(&Value::Object(output.clone()), path).is_none() { 451 + set_nested_path(&mut output, path, value.clone()); 452 + matched += 1; 453 + } 454 + } 455 + } 456 + 457 + if matched == 0 { 458 + return Err(TransmogrifyError::NoMorphismFound); 459 + } 460 + 461 + let quality = matched as f64 / dest_properties.len().max(1) as f64; 462 + Ok(TransmogrifyResult { 463 + record: Value::Object(output), 464 + quality, 465 + }) 466 + } 467 + 468 + /// Extract all external ref NSIDs from a lexicon schema JSON. 469 + pub fn extract_schema_refs(schema_json: &Value) -> HashSet<String> { 470 + let mut refs = HashSet::new(); 471 + if let Some(defs) = schema_json.get("defs").and_then(|d| d.as_object()) { 472 + for (_def_name, def) in defs { 473 + collect_refs_from_def(def, &mut refs); 474 + } 475 + } 476 + refs 477 + } 478 + 479 + /// Enrich a schema JSON by inlining external ref definitions. 480 + pub fn enrich_schema_with_refs( 481 + schema_json: &Value, 482 + resolved_schemas: &HashMap<String, Value>, 483 + ) -> Value { 484 + let mut result = schema_json.clone(); 485 + if let Some(defs) = result.get_mut("defs").and_then(|d| d.as_object_mut()) { 486 + let keys: Vec<String> = defs.keys().cloned().collect(); 487 + for key in keys { 488 + if let Some(def) = defs.get_mut(&key) { 489 + enrich_def(def, resolved_schemas); 490 + } 491 + } 492 + } 493 + 494 + // Add union variant definitions keyed by NSID with dots replaced by underscores. 495 + let union_refs = collect_union_refs(&result); 496 + if !union_refs.is_empty() 497 + && let Some(defs) = result.get_mut("defs").and_then(|d| d.as_object_mut()) 498 + { 499 + for ref_nsid in union_refs { 500 + let safe_key = ref_nsid.replace('.', "_"); 501 + if defs.contains_key(&safe_key) { 502 + continue; 503 + } 504 + if let Some(resolved_def) = resolve_ref_definition(&ref_nsid, resolved_schemas) { 505 + defs.insert(safe_key, resolved_def); 506 + } 507 + } 508 + } 509 + 510 + result 511 + } 512 + 513 + // -- Private helpers -- 514 + 515 + /// Extract property names from a lexicon schema's main record definition. 516 + fn extract_schema_properties(schema_json: &Value) -> Vec<String> { 517 + schema_json 518 + .pointer("/defs/main/record/properties") 519 + .and_then(|p| p.as_object()) 520 + .map(|obj| obj.keys().cloned().collect()) 521 + .unwrap_or_default() 522 + } 523 + 524 + /// Get the schema definition for a named property from a lexicon schema's 525 + /// main record definition. 526 + fn get_property_schema_def(schema_json: &Value, prop_name: &str) -> Option<Value> { 527 + schema_json 528 + .pointer(&format!("/defs/main/record/properties/{prop_name}")) 529 + .cloned() 530 + } 531 + 532 + /// Extract candidate type NSIDs from a property definition that is a union or ref. 533 + fn get_union_or_ref_candidates(prop_def: &Value) -> Vec<String> { 534 + let type_str = prop_def.get("type").and_then(|t| t.as_str()).unwrap_or(""); 535 + match type_str { 536 + "union" => prop_def 537 + .get("refs") 538 + .and_then(|r| r.as_array()) 539 + .map(|arr| { 540 + arr.iter() 541 + .filter_map(|v| v.as_str().map(String::from)) 542 + .collect() 543 + }) 544 + .unwrap_or_default(), 545 + "ref" => prop_def 546 + .get("ref") 547 + .and_then(|r| r.as_str()) 548 + .map(|s| vec![s.to_string()]) 549 + .unwrap_or_default(), 550 + _ => vec![], 551 + } 552 + } 553 + 554 + /// Resolve a dotted path through nested JSON objects. 555 + fn resolve_dotted_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { 556 + if !path.contains('.') { 557 + return value.as_object().and_then(|obj| obj.get(path)); 558 + } 559 + let mut current = value; 560 + for segment in path.split('.') { 561 + current = current.as_object().and_then(|obj| obj.get(segment))?; 562 + } 563 + Some(current) 564 + } 565 + 566 + /// Set a value at a dotted path in a JSON map, creating intermediate objects as needed. 567 + fn set_nested_path(output: &mut serde_json::Map<String, Value>, path: &str, value: Value) { 568 + let segments: Vec<&str> = path.split('.').collect(); 569 + if segments.len() == 1 { 570 + output.insert(segments[0].to_string(), value); 571 + return; 572 + } 573 + let mut current = output; 574 + for segment in &segments[..segments.len() - 1] { 575 + let key = segment.to_string(); 576 + if !current.contains_key(&key) { 577 + current.insert(key.clone(), Value::Object(serde_json::Map::new())); 578 + } 579 + current = current 580 + .get_mut(&key) 581 + .unwrap() 582 + .as_object_mut() 583 + .expect("intermediate path segment is not an object"); 584 + } 585 + current.insert(segments.last().unwrap().to_string(), value); 586 + } 587 + 588 + /// Find the record body vertex in a panproto ATProto schema. 589 + #[cfg(feature = "panproto")] 590 + fn find_record_body_vertex(schema: &panproto_core::schema::Schema) -> Option<String> { 591 + for vertex in schema.vertices.values() { 592 + if vertex.id.ends_with(":body") { 593 + return Some(vertex.id.to_string()); 594 + } 595 + } 596 + None 597 + } 598 + 599 + /// Recursively collect external ref NSIDs from a schema definition. 600 + fn collect_refs_from_def(def: &Value, refs: &mut HashSet<String>) { 601 + let type_str = def.get("type").and_then(|t| t.as_str()).unwrap_or(""); 602 + match type_str { 603 + "ref" => { 604 + if let Some(r) = def.get("ref").and_then(|v| v.as_str()) 605 + && !r.starts_with('#') 606 + { 607 + refs.insert(r.to_string()); 608 + } 609 + } 610 + "union" => { 611 + if let Some(arr) = def.get("refs").and_then(|v| v.as_array()) { 612 + for r in arr.iter().filter_map(|v| v.as_str()) { 613 + if !r.starts_with('#') { 614 + refs.insert(r.to_string()); 615 + } 616 + } 617 + } 618 + } 619 + "record" => { 620 + if let Some(record) = def.get("record") { 621 + collect_refs_from_def(record, refs); 622 + } 623 + } 624 + "object" => { 625 + if let Some(props) = def.get("properties").and_then(|p| p.as_object()) { 626 + for (_name, prop_def) in props { 627 + collect_refs_from_def(prop_def, refs); 628 + } 629 + } 630 + } 631 + "array" => { 632 + if let Some(items) = def.get("items") { 633 + collect_refs_from_def(items, refs); 634 + } 635 + } 636 + _ => {} 637 + } 638 + } 639 + 640 + /// Collect all external union ref NSIDs from a schema's defs. 641 + fn collect_union_refs(schema_json: &Value) -> Vec<String> { 642 + let mut refs = Vec::new(); 643 + if let Some(defs) = schema_json.get("defs").and_then(|d| d.as_object()) { 644 + for (_def_name, def) in defs { 645 + collect_union_refs_from_def(def, &mut refs); 646 + } 647 + } 648 + refs 649 + } 650 + 651 + /// Recursively collect union ref NSIDs from a schema definition. 652 + fn collect_union_refs_from_def(def: &Value, refs: &mut Vec<String>) { 653 + let type_str = def.get("type").and_then(|t| t.as_str()).unwrap_or(""); 654 + match type_str { 655 + "union" => { 656 + if let Some(arr) = def.get("refs").and_then(|v| v.as_array()) { 657 + for r in arr.iter().filter_map(|v| v.as_str()) { 658 + if !r.starts_with('#') { 659 + refs.push(r.to_string()); 660 + } 661 + } 662 + } 663 + } 664 + "record" => { 665 + if let Some(record) = def.get("record") { 666 + collect_union_refs_from_def(record, refs); 667 + } 668 + } 669 + "object" => { 670 + if let Some(props) = def.get("properties").and_then(|p| p.as_object()) { 671 + for (_name, prop_def) in props { 672 + collect_union_refs_from_def(prop_def, refs); 673 + } 674 + } 675 + } 676 + "array" => { 677 + if let Some(items) = def.get("items") { 678 + collect_union_refs_from_def(items, refs); 679 + } 680 + } 681 + _ => {} 682 + } 683 + } 684 + 685 + /// Recursively enrich a schema definition by inlining external refs. 686 + fn enrich_def(def: &mut Value, resolved_schemas: &HashMap<String, Value>) { 687 + let type_str = def 688 + .get("type") 689 + .and_then(|t| t.as_str()) 690 + .unwrap_or("") 691 + .to_string(); 692 + match type_str.as_str() { 693 + "ref" => { 694 + if let Some(ref_target) = def.get("ref").and_then(|v| v.as_str()).map(String::from) 695 + && let Some(resolved_def) = resolve_ref_definition(&ref_target, resolved_schemas) 696 + { 697 + *def = resolved_def; 698 + } 699 + } 700 + "record" => { 701 + if let Some(record) = def.get_mut("record") { 702 + enrich_def(record, resolved_schemas); 703 + } 704 + } 705 + "object" => { 706 + if let Some(props) = def.get_mut("properties").and_then(|p| p.as_object_mut()) { 707 + let keys: Vec<String> = props.keys().cloned().collect(); 708 + for key in keys { 709 + if let Some(prop_def) = props.get_mut(&key) { 710 + enrich_def(prop_def, resolved_schemas); 711 + } 712 + } 713 + } 714 + } 715 + "array" => { 716 + if let Some(items) = def.get_mut("items") { 717 + enrich_def(items, resolved_schemas); 718 + } 719 + } 720 + "union" => { 721 + // For unions, we don't inline individual variants since panproto 722 + // creates variant vertices from the refs array. 723 + } 724 + _ => {} 725 + } 726 + } 727 + 728 + /// Resolve a ref NSID to an inline definition from the resolved schemas map. 729 + fn resolve_ref_definition( 730 + ref_target: &str, 731 + resolved_schemas: &HashMap<String, Value>, 732 + ) -> Option<Value> { 733 + let (nsid, fragment) = if let Some(idx) = ref_target.find('#') { 734 + (&ref_target[..idx], Some(&ref_target[idx + 1..])) 735 + } else { 736 + (ref_target, None) 737 + }; 738 + 739 + let schema = resolved_schemas.get(nsid)?; 740 + 741 + let def = if let Some(frag) = fragment { 742 + schema.pointer(&format!("/defs/{frag}")) 743 + } else { 744 + schema 745 + .pointer("/defs/main/record") 746 + .or_else(|| schema.pointer("/defs/main")) 747 + }?; 748 + 749 + Some(def.clone()) 750 + } 751 + 752 + /// Resolve a `$type` NSID to a minimal wrapping schema suitable for transmogrify. 753 + fn resolve_nested_type_schema( 754 + type_nsid: &str, 755 + parent_schema_json: &Value, 756 + resolved_refs: &HashMap<String, Value>, 757 + ) -> Option<Value> { 758 + let (base_nsid, fragment) = if let Some(idx) = type_nsid.find('#') { 759 + (&type_nsid[..idx], Some(&type_nsid[idx + 1..])) 760 + } else { 761 + (type_nsid, None) 762 + }; 763 + 764 + let parent_id = parent_schema_json 765 + .get("id") 766 + .and_then(|v| v.as_str()) 767 + .unwrap_or(""); 768 + 769 + let (object_def, containing_schema) = if let Some(frag) = fragment { 770 + if base_nsid == parent_id || base_nsid.is_empty() { 771 + let def = parent_schema_json.pointer(&format!("/defs/{frag}"))?; 772 + (def, parent_schema_json) 773 + } else { 774 + let schema = resolved_refs.get(base_nsid)?; 775 + let def = schema.pointer(&format!("/defs/{frag}"))?; 776 + (def, schema) 777 + } 778 + } else if let Some(schema) = resolved_refs.get(base_nsid) { 779 + let def = schema 780 + .pointer("/defs/main/record") 781 + .or_else(|| schema.pointer("/defs/main"))?; 782 + (def, schema) 783 + } else { 784 + let safe_key = base_nsid.replace('.', "_"); 785 + let def = parent_schema_json.pointer(&format!("/defs/{safe_key}"))?; 786 + (def, parent_schema_json) 787 + }; 788 + 789 + let record_def = if object_def.get("type").and_then(|t| t.as_str()) == Some("object") { 790 + object_def.clone() 791 + } else { 792 + object_def 793 + .get("record") 794 + .cloned() 795 + .unwrap_or_else(|| object_def.clone()) 796 + }; 797 + 798 + let record_def = resolve_internal_refs(&record_def, containing_schema); 799 + 800 + Some(serde_json::json!({ 801 + "lexicon": 1, 802 + "id": type_nsid.replace('#', "."), 803 + "defs": { 804 + "main": { 805 + "type": "record", 806 + "key": "tid", 807 + "record": record_def 808 + } 809 + } 810 + })) 811 + } 812 + 813 + /// Recursively resolve internal fragment refs within a definition. 814 + fn resolve_internal_refs(def: &Value, containing_schema: &Value) -> Value { 815 + let type_str = def 816 + .get("type") 817 + .and_then(|t| t.as_str()) 818 + .unwrap_or("") 819 + .to_string(); 820 + match type_str.as_str() { 821 + "ref" => { 822 + if let Some(ref_target) = def.get("ref").and_then(|v| v.as_str()) 823 + && let Some(frag) = ref_target.strip_prefix('#') 824 + && let Some(resolved) = containing_schema.pointer(&format!("/defs/{frag}")) 825 + { 826 + return resolve_internal_refs(resolved, containing_schema); 827 + } 828 + def.clone() 829 + } 830 + "object" => { 831 + let mut result = def.clone(); 832 + if let Some(props) = result.get_mut("properties").and_then(|p| p.as_object_mut()) { 833 + let keys: Vec<String> = props.keys().cloned().collect(); 834 + for key in keys { 835 + if let Some(prop_def) = props.get(&key).cloned() { 836 + let resolved = resolve_internal_refs(&prop_def, containing_schema); 837 + props.insert(key, resolved); 838 + } 839 + } 840 + } 841 + result 842 + } 843 + "array" => { 844 + let mut result = def.clone(); 845 + if let Some(items) = result.get("items").cloned() { 846 + let resolved = resolve_internal_refs(&items, containing_schema); 847 + result 848 + .as_object_mut() 849 + .unwrap() 850 + .insert("items".to_string(), resolved); 851 + } 852 + result 853 + } 854 + _ => def.clone(), 855 + } 856 + } 857 + 858 + /// Known format-aware field name aliases. 859 + const NAME_ALIASES: &[(&str, &str)] = &[("uri", "url"), ("url", "uri")]; 860 + 861 + /// Deep-match a source value into a destination schema structure by recursively 862 + /// searching for field names that match destination leaf properties. 863 + fn deep_match_nested(src_val: &Value, dest_schema_json: &Value) -> Option<Value> { 864 + let record_props = dest_schema_json 865 + .pointer("/defs/main/record/properties") 866 + .and_then(|p| p.as_object())?; 867 + 868 + build_deep_matched_object(src_val, record_props) 869 + } 870 + 871 + /// Recursively build an output object matching the destination property structure. 872 + fn build_deep_matched_object( 873 + src_val: &Value, 874 + dest_props: &serde_json::Map<String, Value>, 875 + ) -> Option<Value> { 876 + let mut output = serde_json::Map::new(); 877 + let mut matched = 0usize; 878 + 879 + for (prop_name, prop_def) in dest_props { 880 + let type_str = prop_def.get("type").and_then(|t| t.as_str()).unwrap_or(""); 881 + 882 + if type_str == "object" { 883 + if let Some(nested_props) = prop_def.get("properties").and_then(|p| p.as_object()) 884 + && let Some(nested_val) = build_deep_matched_object(src_val, nested_props) 885 + { 886 + output.insert(prop_name.clone(), nested_val); 887 + matched += 1; 888 + } 889 + } else if let Some(found) = find_value_by_name(src_val, prop_name) { 890 + output.insert(prop_name.clone(), found.clone()); 891 + matched += 1; 892 + } 893 + } 894 + 895 + if matched > 0 { 896 + Some(Value::Object(output)) 897 + } else { 898 + None 899 + } 900 + } 901 + 902 + /// Recursively search a JSON value for a field with the given name. 903 + fn find_value_by_name<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { 904 + let obj = value.as_object()?; 905 + 906 + if let Some(v) = obj.get(name) 907 + && (!v.is_object() || v.get("$type").is_some()) 908 + { 909 + return Some(v); 910 + } 911 + 912 + for &(from, to) in NAME_ALIASES { 913 + if name == from 914 + && let Some(v) = obj.get(to) 915 + && (!v.is_object() || v.get("$type").is_some()) 916 + { 917 + return Some(v); 918 + } 919 + } 920 + 921 + for (_, child) in obj { 922 + if child.is_object() 923 + && let Some(found) = find_value_by_name(child, name) 924 + { 925 + return Some(found); 926 + } 927 + } 928 + 929 + None 930 + } 931 + 932 + /// Count leaf properties in a destination schema's record definition. 933 + fn count_dest_schema_leaves(schema_json: &Value) -> usize { 934 + fn count_props(props: &serde_json::Map<String, Value>) -> usize { 935 + let mut count = 0; 936 + for (_, def) in props { 937 + let type_str = def.get("type").and_then(|t| t.as_str()).unwrap_or(""); 938 + if type_str == "object" { 939 + if let Some(nested) = def.get("properties").and_then(|p| p.as_object()) { 940 + count += count_props(nested); 941 + } 942 + } else { 943 + count += 1; 944 + } 945 + } 946 + count 947 + } 948 + 949 + schema_json 950 + .pointer("/defs/main/record/properties") 951 + .and_then(|p| p.as_object()) 952 + .map(count_props) 953 + .unwrap_or(0) 954 + } 955 + 956 + /// Count leaf (non-object) values in a JSON map, recursing into nested objects. 957 + fn count_leaf_values(obj: &serde_json::Map<String, Value>) -> usize { 958 + let mut count = 0; 959 + for (_, v) in obj { 960 + if let Some(nested) = v.as_object() { 961 + count += count_leaf_values(nested); 962 + } else { 963 + count += 1; 964 + } 965 + } 966 + count 967 + } 968 + 969 + #[cfg(test)] 970 + mod tests { 971 + use super::*; 972 + use serde_json::json; 973 + 974 + #[test] 975 + fn test_transmogrify_json_direct_match() { 976 + let source_schema = json!({ 977 + "lexicon": 1, 978 + "id": "com.example.source", 979 + "defs": { "main": { "type": "record", "key": "tid", "record": { 980 + "type": "object", 981 + "properties": { 982 + "text": { "type": "string" }, 983 + "lang": { "type": "string" } 984 + } 985 + }}} 986 + }); 987 + let dest_schema = json!({ 988 + "lexicon": 1, 989 + "id": "com.example.dest", 990 + "defs": { "main": { "type": "record", "key": "tid", "record": { 991 + "type": "object", 992 + "properties": { 993 + "text": { "type": "string" }, 994 + "title": { "type": "string" } 995 + } 996 + }}} 997 + }); 998 + let record = json!({ "text": "hello", "lang": "en" }); 999 + 1000 + let result = transmogrify_json(&record, &source_schema, &dest_schema, None, None).unwrap(); 1001 + assert_eq!(result.record["text"], "hello"); 1002 + assert!(result.record.get("lang").is_none()); 1003 + assert!(result.quality > 0.0); 1004 + } 1005 + 1006 + #[test] 1007 + fn test_transmogrify_json_with_mappings() { 1008 + let source_schema = json!({ 1009 + "lexicon": 1, 1010 + "id": "com.example.source", 1011 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1012 + "type": "object", 1013 + "properties": { "body": { "type": "string" } } 1014 + }}} 1015 + }); 1016 + let dest_schema = json!({ 1017 + "lexicon": 1, 1018 + "id": "com.example.dest", 1019 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1020 + "type": "object", 1021 + "properties": { "text": { "type": "string" } } 1022 + }}} 1023 + }); 1024 + let record = json!({ "body": "hello" }); 1025 + let mappings = TransmogrifyMappings { 1026 + field_mappings: [("body".to_string(), "text".to_string())] 1027 + .into_iter() 1028 + .collect(), 1029 + defaults: HashMap::new(), 1030 + }; 1031 + 1032 + let result = 1033 + transmogrify_json(&record, &source_schema, &dest_schema, Some(&mappings), None) 1034 + .unwrap(); 1035 + assert_eq!(result.record["text"], "hello"); 1036 + } 1037 + 1038 + #[test] 1039 + fn test_transmogrify_json_with_defaults() { 1040 + let source_schema = json!({ 1041 + "lexicon": 1, 1042 + "id": "com.example.source", 1043 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1044 + "type": "object", 1045 + "properties": { "text": { "type": "string" } } 1046 + }}} 1047 + }); 1048 + let dest_schema = json!({ 1049 + "lexicon": 1, 1050 + "id": "com.example.dest", 1051 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1052 + "type": "object", 1053 + "properties": { 1054 + "text": { "type": "string" }, 1055 + "lang": { "type": "string" } 1056 + } 1057 + }}} 1058 + }); 1059 + let record = json!({ "text": "hello" }); 1060 + let mappings = TransmogrifyMappings { 1061 + field_mappings: HashMap::new(), 1062 + defaults: [("lang".to_string(), json!("en"))].into_iter().collect(), 1063 + }; 1064 + 1065 + let result = 1066 + transmogrify_json(&record, &source_schema, &dest_schema, Some(&mappings), None) 1067 + .unwrap(); 1068 + assert_eq!(result.record["text"], "hello"); 1069 + assert_eq!(result.record["lang"], "en"); 1070 + } 1071 + 1072 + #[test] 1073 + fn test_transmogrify_json_no_dest_properties() { 1074 + let source_schema = json!({ "lexicon": 1, "id": "a", "defs": {} }); 1075 + let dest_schema = json!({ "lexicon": 1, "id": "b", "defs": {} }); 1076 + let record = json!({ "text": "hello" }); 1077 + 1078 + let err = transmogrify_json(&record, &source_schema, &dest_schema, None, None).unwrap_err(); 1079 + assert!(matches!(err, TransmogrifyError::NoMorphismFound)); 1080 + } 1081 + 1082 + #[test] 1083 + fn test_transmogrify_json_record_not_object() { 1084 + let source_schema = json!({ 1085 + "lexicon": 1, "id": "a", 1086 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1087 + "type": "object", "properties": { "x": { "type": "string" } } 1088 + }}} 1089 + }); 1090 + let dest_schema = source_schema.clone(); 1091 + let record = json!("not an object"); 1092 + 1093 + let err = transmogrify_json(&record, &source_schema, &dest_schema, None, None).unwrap_err(); 1094 + assert!(matches!(err, TransmogrifyError::ParseRecord(_))); 1095 + } 1096 + 1097 + #[test] 1098 + fn test_extract_schema_refs() { 1099 + let schema = json!({ 1100 + "defs": { 1101 + "main": { 1102 + "type": "record", 1103 + "record": { 1104 + "type": "object", 1105 + "properties": { 1106 + "embed": { "type": "ref", "ref": "com.example.embed" }, 1107 + "local": { "type": "ref", "ref": "#localDef" } 1108 + } 1109 + } 1110 + } 1111 + } 1112 + }); 1113 + let refs = extract_schema_refs(&schema); 1114 + assert!(refs.contains("com.example.embed")); 1115 + assert!(!refs.contains("#localDef")); 1116 + } 1117 + 1118 + #[test] 1119 + fn test_enrich_schema_with_refs() { 1120 + let schema = json!({ 1121 + "defs": { 1122 + "main": { 1123 + "type": "record", 1124 + "record": { 1125 + "type": "object", 1126 + "properties": { 1127 + "embed": { "type": "ref", "ref": "com.example.embed" } 1128 + } 1129 + } 1130 + } 1131 + } 1132 + }); 1133 + let mut resolved = HashMap::new(); 1134 + resolved.insert( 1135 + "com.example.embed".to_string(), 1136 + json!({ 1137 + "defs": { 1138 + "main": { 1139 + "type": "object", 1140 + "properties": { "url": { "type": "string" } } 1141 + } 1142 + } 1143 + }), 1144 + ); 1145 + 1146 + let enriched = enrich_schema_with_refs(&schema, &resolved); 1147 + let embed_def = enriched 1148 + .pointer("/defs/main/record/properties/embed") 1149 + .unwrap(); 1150 + assert_eq!(embed_def["type"], "object"); 1151 + } 1152 + 1153 + #[test] 1154 + fn test_transmogrify_shared_fields() { 1155 + let from = json!({ 1156 + "lexicon": 1, 1157 + "id": "com.example.profileA", 1158 + "defs": { "main": { "type": "record", "key": "literal:self", "record": { 1159 + "type": "object", 1160 + "properties": { 1161 + "displayName": { "type": "string", "maxLength": 640 }, 1162 + "description": { "type": "string", "maxLength": 2560 }, 1163 + "uniqueToA": { "type": "boolean" } 1164 + } 1165 + }}} 1166 + }); 1167 + let to = json!({ 1168 + "lexicon": 1, 1169 + "id": "com.example.profileB", 1170 + "defs": { "main": { "type": "record", "key": "literal:self", "record": { 1171 + "type": "object", 1172 + "properties": { 1173 + "displayName": { "type": "string", "maxLength": 640 }, 1174 + "description": { "type": "string", "maxLength": 2560 }, 1175 + "uniqueToB": { "type": "string" } 1176 + } 1177 + }}} 1178 + }); 1179 + let record = json!({ 1180 + "$type": "com.example.profileA", 1181 + "displayName": "Alice", 1182 + "description": "Hello world", 1183 + "uniqueToA": true 1184 + }); 1185 + let result = transmogrify(&from, &to, &record, None).unwrap(); 1186 + let obj = result.record.as_object().unwrap(); 1187 + assert_eq!(obj.get("displayName").unwrap(), "Alice"); 1188 + assert_eq!(obj.get("description").unwrap(), "Hello world"); 1189 + assert!(obj.get("uniqueToA").is_none()); 1190 + assert!(obj.get("uniqueToB").is_none()); 1191 + } 1192 + 1193 + #[test] 1194 + fn test_transmogrify_with_explicit_mappings() { 1195 + let from = json!({ 1196 + "lexicon": 1, 1197 + "id": "com.example.source", 1198 + "defs": { "main": { "type": "record", "key": "literal:self", "record": { 1199 + "type": "object", 1200 + "properties": { 1201 + "title": { "type": "string" }, 1202 + "body": { "type": "string" } 1203 + } 1204 + }}} 1205 + }); 1206 + let to = json!({ 1207 + "lexicon": 1, 1208 + "id": "com.example.dest", 1209 + "defs": { "main": { "type": "record", "key": "literal:self", "record": { 1210 + "type": "object", 1211 + "properties": { 1212 + "displayName": { "type": "string" }, 1213 + "description": { "type": "string" } 1214 + } 1215 + }}} 1216 + }); 1217 + let record = json!({ "title": "My Title", "body": "Some content" }); 1218 + let mappings = TransmogrifyMappings { 1219 + field_mappings: HashMap::from([ 1220 + ("title".to_string(), "displayName".to_string()), 1221 + ("body".to_string(), "description".to_string()), 1222 + ]), 1223 + ..Default::default() 1224 + }; 1225 + let result = transmogrify(&from, &to, &record, Some(&mappings)).unwrap(); 1226 + let obj = result.record.as_object().unwrap(); 1227 + assert_eq!(obj.get("displayName").unwrap(), "My Title"); 1228 + assert_eq!(obj.get("description").unwrap(), "Some content"); 1229 + assert!((result.quality - 1.0).abs() < f64::EPSILON); 1230 + } 1231 + 1232 + #[test] 1233 + fn test_transmogrify_no_shared_fields() { 1234 + let from = json!({ 1235 + "lexicon": 1, 1236 + "id": "com.example.schemaA", 1237 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1238 + "type": "object", 1239 + "properties": { 1240 + "alpha": { "type": "string" }, 1241 + "beta": { "type": "string" } 1242 + } 1243 + }}} 1244 + }); 1245 + let to = json!({ 1246 + "lexicon": 1, 1247 + "id": "com.example.schemaB", 1248 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1249 + "type": "object", 1250 + "properties": { 1251 + "gamma": { "type": "boolean" }, 1252 + "delta": { "type": "integer" } 1253 + } 1254 + }}} 1255 + }); 1256 + let record = json!({ "alpha": "val", "beta": "val2" }); 1257 + let result = transmogrify(&from, &to, &record, None); 1258 + // Either panproto finds a structural mapping or strategy 3 fails 1259 + if let Ok(r) = &result { 1260 + assert!(r.quality > 0.0); 1261 + } 1262 + } 1263 + 1264 + #[test] 1265 + fn test_transmogrify_dotted_path_writes() { 1266 + let from = json!({ 1267 + "lexicon": 1, 1268 + "id": "com.example.card", 1269 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1270 + "type": "object", 1271 + "properties": { 1272 + "url": { "type": "string" }, 1273 + "content": { "type": "object", "properties": { 1274 + "metadata": { "type": "object", "properties": { 1275 + "title": { "type": "string" }, 1276 + "description": { "type": "string" } 1277 + }} 1278 + }}, 1279 + "createdAt": { "type": "string", "format": "datetime" } 1280 + } 1281 + }}} 1282 + }); 1283 + let to = json!({ 1284 + "lexicon": 1, 1285 + "id": "com.example.post", 1286 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1287 + "type": "object", 1288 + "properties": { 1289 + "text": { "type": "string" }, 1290 + "embed": { "type": "object", "properties": { 1291 + "external": { "type": "object", "properties": { 1292 + "uri": { "type": "string" }, 1293 + "title": { "type": "string" }, 1294 + "description": { "type": "string" } 1295 + }} 1296 + }}, 1297 + "createdAt": { "type": "string", "format": "datetime" } 1298 + } 1299 + }}} 1300 + }); 1301 + let record = json!({ 1302 + "url": "https://example.com", 1303 + "content": { "metadata": { "title": "Example", "description": "An example page" } }, 1304 + "createdAt": "2024-01-01T00:00:00.000Z" 1305 + }); 1306 + let mappings = TransmogrifyMappings { 1307 + field_mappings: HashMap::from([ 1308 + ("url".to_string(), "embed.external.uri".to_string()), 1309 + ( 1310 + "content.metadata.title".to_string(), 1311 + "embed.external.title".to_string(), 1312 + ), 1313 + ( 1314 + "content.metadata.description".to_string(), 1315 + "embed.external.description".to_string(), 1316 + ), 1317 + ]), 1318 + defaults: HashMap::from([ 1319 + ("text".to_string(), json!("")), 1320 + ("embed.$type".to_string(), json!("app.bsky.embed.external")), 1321 + ]), 1322 + }; 1323 + let result = transmogrify(&from, &to, &record, Some(&mappings)).unwrap(); 1324 + let obj = result.record.as_object().unwrap(); 1325 + assert_eq!(obj.get("text").unwrap(), ""); 1326 + assert_eq!(obj.get("createdAt").unwrap(), "2024-01-01T00:00:00.000Z"); 1327 + 1328 + let embed = obj.get("embed").unwrap().as_object().unwrap(); 1329 + assert_eq!(embed.get("$type").unwrap(), "app.bsky.embed.external"); 1330 + 1331 + let external = embed.get("external").unwrap().as_object().unwrap(); 1332 + assert_eq!(external.get("uri").unwrap(), "https://example.com"); 1333 + assert_eq!(external.get("title").unwrap(), "Example"); 1334 + assert_eq!(external.get("description").unwrap(), "An example page"); 1335 + } 1336 + 1337 + #[test] 1338 + fn test_transmogrify_recursive_nested_types() { 1339 + let from = json!({ 1340 + "lexicon": 1, 1341 + "id": "com.example.card", 1342 + "defs": { 1343 + "main": { "type": "record", "key": "tid", "record": { 1344 + "type": "object", 1345 + "properties": { 1346 + "content": { "type": "union", "refs": ["#urlContent"] }, 1347 + "createdAt": { "type": "string", "format": "datetime" } 1348 + } 1349 + }}, 1350 + "urlContent": { "type": "object", "properties": { 1351 + "url": { "type": "string", "format": "uri" }, 1352 + "title": { "type": "string" }, 1353 + "description": { "type": "string" } 1354 + }} 1355 + } 1356 + }); 1357 + let to = json!({ 1358 + "lexicon": 1, 1359 + "id": "com.example.post", 1360 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1361 + "type": "object", 1362 + "properties": { 1363 + "text": { "type": "string" }, 1364 + "embed": { "type": "union", "refs": ["com.example.embed.link"] }, 1365 + "createdAt": { "type": "string", "format": "datetime" } 1366 + } 1367 + }}} 1368 + }); 1369 + let embed_schema = json!({ 1370 + "lexicon": 1, 1371 + "id": "com.example.embed.link", 1372 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1373 + "type": "object", 1374 + "properties": { 1375 + "url": { "type": "string", "format": "uri" }, 1376 + "title": { "type": "string" }, 1377 + "description": { "type": "string" } 1378 + } 1379 + }}} 1380 + }); 1381 + let resolved_refs = HashMap::from([("com.example.embed.link".to_string(), embed_schema)]); 1382 + let record = json!({ 1383 + "content": { 1384 + "$type": "com.example.card#urlContent", 1385 + "url": "https://example.com", 1386 + "title": "Example Site", 1387 + "description": "An example" 1388 + }, 1389 + "createdAt": "2024-01-01T00:00:00.000Z" 1390 + }); 1391 + 1392 + let result = 1393 + transmogrify_with_refs(&from, &to, &record, None, Some(&resolved_refs)).unwrap(); 1394 + let obj = result.record.as_object().unwrap(); 1395 + assert_eq!(obj.get("createdAt").unwrap(), "2024-01-01T00:00:00.000Z"); 1396 + 1397 + let embed = obj.get("embed").unwrap().as_object().unwrap(); 1398 + assert_eq!(embed.get("$type").unwrap(), "com.example.embed.link"); 1399 + assert_eq!(embed.get("url").unwrap(), "https://example.com"); 1400 + assert_eq!(embed.get("title").unwrap(), "Example Site"); 1401 + assert_eq!(embed.get("description").unwrap(), "An example"); 1402 + } 1403 + 1404 + #[test] 1405 + fn test_transmogrify_recursive_with_ref_indirection() { 1406 + let from = json!({ 1407 + "lexicon": 1, 1408 + "id": "network.cosmik.card", 1409 + "defs": { 1410 + "main": { "type": "record", "key": "tid", "record": { 1411 + "type": "object", 1412 + "properties": { 1413 + "type": { "type": "string" }, 1414 + "content": { "type": "union", "refs": ["#urlContent"] }, 1415 + "createdAt": { "type": "string", "format": "datetime" } 1416 + } 1417 + }}, 1418 + "urlContent": { "type": "object", "properties": { 1419 + "url": { "type": "string", "format": "uri" }, 1420 + "metadata": { "type": "ref", "ref": "#urlMetadata" } 1421 + }}, 1422 + "urlMetadata": { "type": "object", "properties": { 1423 + "title": { "type": "string" }, 1424 + "description": { "type": "string" }, 1425 + "imageUrl": { "type": "string" }, 1426 + "siteName": { "type": "string" } 1427 + }} 1428 + } 1429 + }); 1430 + let to = json!({ 1431 + "lexicon": 1, 1432 + "id": "app.bsky.feed.post", 1433 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1434 + "type": "object", 1435 + "properties": { 1436 + "text": { "type": "string" }, 1437 + "embed": { "type": "union", "refs": ["app.bsky.embed.external"] }, 1438 + "createdAt": { "type": "string", "format": "datetime" } 1439 + } 1440 + }}} 1441 + }); 1442 + let embed_schema = json!({ 1443 + "lexicon": 1, 1444 + "id": "app.bsky.embed.external", 1445 + "defs": { 1446 + "main": { "type": "object", "required": ["external"], "properties": { 1447 + "external": { "type": "ref", "ref": "#external" } 1448 + }}, 1449 + "external": { "type": "object", "required": ["uri", "title", "description"], "properties": { 1450 + "uri": { "type": "string", "format": "uri" }, 1451 + "title": { "type": "string" }, 1452 + "description": { "type": "string" } 1453 + }} 1454 + } 1455 + }); 1456 + let resolved_refs = HashMap::from([("app.bsky.embed.external".to_string(), embed_schema)]); 1457 + let record = json!({ 1458 + "type": "URL", 1459 + "$type": "network.cosmik.card", 1460 + "content": { 1461 + "$type": "network.cosmik.card#urlContent", 1462 + "url": "https://zipcodefirst.com/", 1463 + "metadata": { 1464 + "$type": "network.cosmik.card#urlMetadata", 1465 + "title": "Put the ZIP code first.", 1466 + "description": "A ZIP code is 5 characters.", 1467 + "imageUrl": "https://zipcodefirst.com/og.png", 1468 + "siteName": "zipcodefirst.com" 1469 + } 1470 + }, 1471 + "createdAt": "2026-03-11T18:41:46.651Z" 1472 + }); 1473 + let mappings = TransmogrifyMappings { 1474 + field_mappings: HashMap::from([( 1475 + "content.metadata.description".to_string(), 1476 + "text".to_string(), 1477 + )]), 1478 + ..Default::default() 1479 + }; 1480 + 1481 + let result = 1482 + transmogrify_with_refs(&from, &to, &record, Some(&mappings), Some(&resolved_refs)) 1483 + .unwrap(); 1484 + let obj = result.record.as_object().unwrap(); 1485 + assert_eq!(obj.get("text").unwrap(), "A ZIP code is 5 characters."); 1486 + assert_eq!(obj.get("createdAt").unwrap(), "2026-03-11T18:41:46.651Z"); 1487 + 1488 + let embed = obj.get("embed").unwrap().as_object().unwrap(); 1489 + assert_eq!(embed.get("$type").unwrap(), "app.bsky.embed.external"); 1490 + let external = embed.get("external").unwrap().as_object().unwrap(); 1491 + assert_eq!(external.get("uri").unwrap(), "https://zipcodefirst.com/"); 1492 + assert_eq!(external.get("title").unwrap(), "Put the ZIP code first."); 1493 + assert_eq!( 1494 + external.get("description").unwrap(), 1495 + "A ZIP code is 5 characters." 1496 + ); 1497 + assert!(external.get("imageUrl").is_none()); 1498 + assert!(external.get("siteName").is_none()); 1499 + } 1500 + 1501 + #[test] 1502 + fn test_transmogrify_no_spurious_deep_match() { 1503 + let from = json!({ 1504 + "lexicon": 1, 1505 + "id": "com.example.card", 1506 + "defs": { 1507 + "main": { "type": "record", "key": "tid", "record": { 1508 + "type": "object", 1509 + "properties": { 1510 + "text": { "type": "string" }, 1511 + "content": { "type": "union", "refs": ["#urlContent"] } 1512 + } 1513 + }}, 1514 + "urlContent": { "type": "object", "properties": { 1515 + "url": { "type": "string", "format": "uri" }, 1516 + "title": { "type": "string" } 1517 + }} 1518 + } 1519 + }); 1520 + let to = json!({ 1521 + "lexicon": 1, 1522 + "id": "com.example.post", 1523 + "defs": { 1524 + "main": { "type": "record", "key": "tid", "record": { 1525 + "type": "object", 1526 + "properties": { 1527 + "text": { "type": "string" }, 1528 + "reply": { "type": "ref", "ref": "#replyRef" } 1529 + } 1530 + }}, 1531 + "replyRef": { "type": "object", "properties": { 1532 + "root": { "type": "ref", "ref": "#postRef" }, 1533 + "parent": { "type": "ref", "ref": "#postRef" } 1534 + }}, 1535 + "postRef": { "type": "object", "properties": { 1536 + "uri": { "type": "string", "format": "at-uri" }, 1537 + "cid": { "type": "string", "format": "cid" } 1538 + }} 1539 + } 1540 + }); 1541 + let resolved_refs = HashMap::new(); 1542 + let record = json!({ 1543 + "text": "Hello", 1544 + "content": { 1545 + "$type": "com.example.card#urlContent", 1546 + "url": "https://example.com", 1547 + "title": "Example" 1548 + } 1549 + }); 1550 + 1551 + let result = 1552 + transmogrify_with_refs(&from, &to, &record, None, Some(&resolved_refs)).unwrap(); 1553 + let obj = result.record.as_object().unwrap(); 1554 + assert_eq!(obj.get("text").unwrap(), "Hello"); 1555 + assert!( 1556 + obj.get("reply").is_none(), 1557 + "reply should not be spuriously populated from URL content" 1558 + ); 1559 + } 1560 + 1561 + #[test] 1562 + fn test_enrich_inlines_union_variants_for_nested_resolution() { 1563 + let dest_schema = json!({ 1564 + "lexicon": 1, 1565 + "id": "com.example.post", 1566 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1567 + "type": "object", 1568 + "properties": { 1569 + "text": { "type": "string" }, 1570 + "embed": { "type": "union", "refs": ["com.example.embed.link"] } 1571 + } 1572 + }}} 1573 + }); 1574 + let embed_schema = json!({ 1575 + "lexicon": 1, 1576 + "id": "com.example.embed.link", 1577 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1578 + "type": "object", 1579 + "properties": { 1580 + "uri": { "type": "string", "format": "uri" }, 1581 + "title": { "type": "string" }, 1582 + "description": { "type": "string" } 1583 + } 1584 + }}} 1585 + }); 1586 + 1587 + let mut resolved = HashMap::new(); 1588 + resolved.insert("com.example.embed.link".to_string(), embed_schema); 1589 + 1590 + let enriched = enrich_schema_with_refs(&dest_schema, &resolved); 1591 + assert!( 1592 + enriched.pointer("/defs/com_example_embed_link").is_some(), 1593 + "enriched schema should contain inlined union variant def" 1594 + ); 1595 + 1596 + let empty_refs = HashMap::new(); 1597 + let resolved = resolve_nested_type_schema("com.example.embed.link", &enriched, &empty_refs); 1598 + assert!(resolved.is_some()); 1599 + 1600 + let resolved = resolved.unwrap(); 1601 + let props = resolved.pointer("/defs/main/record/properties").unwrap(); 1602 + assert!(props.get("uri").is_some()); 1603 + assert!(props.get("title").is_some()); 1604 + assert!(props.get("description").is_some()); 1605 + } 1606 + 1607 + #[test] 1608 + fn test_transmogrify_union_nested_via_enriched_schema() { 1609 + let source_schema = json!({ 1610 + "lexicon": 1, 1611 + "id": "com.example.card", 1612 + "defs": { 1613 + "main": { "type": "record", "key": "tid", "record": { 1614 + "type": "object", 1615 + "properties": { 1616 + "content": { "type": "ref", "ref": "#urlContent" }, 1617 + "createdAt": { "type": "string", "format": "datetime" } 1618 + } 1619 + }}, 1620 + "urlContent": { "type": "object", "properties": { 1621 + "url": { "type": "string", "format": "uri" }, 1622 + "metadata": { "type": "ref", "ref": "#urlMetadata" } 1623 + }}, 1624 + "urlMetadata": { "type": "object", "properties": { 1625 + "title": { "type": "string" }, 1626 + "description": { "type": "string" } 1627 + }} 1628 + } 1629 + }); 1630 + let dest_schema = json!({ 1631 + "lexicon": 1, 1632 + "id": "com.example.post", 1633 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1634 + "type": "object", 1635 + "properties": { 1636 + "text": { "type": "string" }, 1637 + "embed": { "type": "union", "refs": ["com.example.embed.link"] }, 1638 + "createdAt": { "type": "string", "format": "datetime" } 1639 + } 1640 + }}} 1641 + }); 1642 + let embed_schema = json!({ 1643 + "lexicon": 1, 1644 + "id": "com.example.embed.link", 1645 + "defs": { "main": { "type": "record", "key": "tid", "record": { 1646 + "type": "object", 1647 + "properties": { 1648 + "uri": { "type": "string", "format": "uri" }, 1649 + "title": { "type": "string" }, 1650 + "description": { "type": "string" } 1651 + } 1652 + }}} 1653 + }); 1654 + 1655 + let record = json!({ 1656 + "$type": "com.example.card", 1657 + "content": { 1658 + "$type": "com.example.card#urlContent", 1659 + "url": "https://example.com/", 1660 + "metadata": { 1661 + "$type": "com.example.card#urlMetadata", 1662 + "title": "Example Title", 1663 + "description": "Example description text" 1664 + } 1665 + }, 1666 + "createdAt": "2026-01-01T00:00:00.000Z" 1667 + }); 1668 + 1669 + let mut resolved_schemas = HashMap::new(); 1670 + resolved_schemas.insert("com.example.embed.link".to_string(), embed_schema); 1671 + 1672 + let enriched_source = enrich_schema_with_refs(&source_schema, &resolved_schemas); 1673 + let enriched_dest = enrich_schema_with_refs(&dest_schema, &resolved_schemas); 1674 + 1675 + let empty_refs = HashMap::new(); 1676 + let result = transmogrify_with_refs( 1677 + &enriched_source, 1678 + &enriched_dest, 1679 + &record, 1680 + None, 1681 + Some(&empty_refs), 1682 + ) 1683 + .unwrap(); 1684 + 1685 + let obj = result.record.as_object().unwrap(); 1686 + assert!(obj.get("createdAt").is_some()); 1687 + 1688 + let embed = obj.get("embed").expect("embed should be present"); 1689 + let embed_obj = embed.as_object().unwrap(); 1690 + 1691 + let has_uri = embed_obj.get("uri").is_some() 1692 + || embed_obj 1693 + .values() 1694 + .filter_map(|v| v.as_object()) 1695 + .any(|nested| nested.get("uri").is_some()); 1696 + assert!(has_uri, "embed should contain uri (from url alias)"); 1697 + 1698 + let has_title = embed_obj.get("title").is_some() 1699 + || embed_obj 1700 + .values() 1701 + .filter_map(|v| v.as_object()) 1702 + .any(|nested| nested.get("title").is_some()); 1703 + assert!(has_title, "embed should contain title from source metadata"); 1704 + } 1705 + 1706 + #[test] 1707 + fn test_transmogrify_explicit_mappings_with_auto_nested() { 1708 + let from = serde_json::json!({ 1709 + "lexicon": 1, 1710 + "id": "network.cosmik.card", 1711 + "defs": { 1712 + "main": { 1713 + "type": "record", 1714 + "key": "tid", 1715 + "record": { 1716 + "type": "object", 1717 + "properties": { 1718 + "type": { "type": "string" }, 1719 + "content": { 1720 + "type": "union", 1721 + "refs": ["#urlContent"] 1722 + }, 1723 + "createdAt": { "type": "string", "format": "datetime" } 1724 + } 1725 + } 1726 + }, 1727 + "urlContent": { 1728 + "type": "object", 1729 + "properties": { 1730 + "url": { "type": "string", "format": "uri" }, 1731 + "metadata": { "type": "ref", "ref": "#urlMetadata" } 1732 + } 1733 + }, 1734 + "urlMetadata": { 1735 + "type": "object", 1736 + "properties": { 1737 + "title": { "type": "string" }, 1738 + "description": { "type": "string" }, 1739 + "imageUrl": { "type": "string" }, 1740 + "siteName": { "type": "string" } 1741 + } 1742 + } 1743 + } 1744 + }); 1745 + 1746 + let to = serde_json::json!({ 1747 + "lexicon": 1, 1748 + "id": "app.bsky.feed.post", 1749 + "defs": { 1750 + "main": { 1751 + "type": "record", 1752 + "key": "tid", 1753 + "record": { 1754 + "type": "object", 1755 + "properties": { 1756 + "text": { "type": "string" }, 1757 + "embed": { 1758 + "type": "union", 1759 + "refs": ["app.bsky.embed.external"] 1760 + }, 1761 + "createdAt": { "type": "string", "format": "datetime" } 1762 + } 1763 + } 1764 + } 1765 + } 1766 + }); 1767 + 1768 + let embed_schema = serde_json::json!({ 1769 + "lexicon": 1, 1770 + "id": "app.bsky.embed.external", 1771 + "defs": { 1772 + "main": { 1773 + "type": "object", 1774 + "required": ["external"], 1775 + "properties": { 1776 + "external": { "type": "ref", "ref": "#external" } 1777 + } 1778 + }, 1779 + "external": { 1780 + "type": "object", 1781 + "required": ["uri", "title", "description"], 1782 + "properties": { 1783 + "uri": { "type": "string", "format": "uri" }, 1784 + "title": { "type": "string" }, 1785 + "description": { "type": "string" } 1786 + } 1787 + } 1788 + } 1789 + }); 1790 + 1791 + let resolved_refs = HashMap::from([("app.bsky.embed.external".to_string(), embed_schema)]); 1792 + 1793 + let record = serde_json::json!({ 1794 + "type": "URL", 1795 + "$type": "network.cosmik.card", 1796 + "content": { 1797 + "$type": "network.cosmik.card#urlContent", 1798 + "url": "https://zipcodefirst.com/", 1799 + "metadata": { 1800 + "$type": "network.cosmik.card#urlMetadata", 1801 + "title": "Put the ZIP code first.", 1802 + "description": "A ZIP code is 5 characters.", 1803 + "imageUrl": "https://zipcodefirst.com/og.png", 1804 + "siteName": "zipcodefirst.com" 1805 + } 1806 + }, 1807 + "createdAt": "2026-03-11T18:41:46.651Z" 1808 + }); 1809 + 1810 + let mappings = TransmogrifyMappings { 1811 + field_mappings: HashMap::from([ 1812 + ( 1813 + "content.metadata.description".to_string(), 1814 + "text".to_string(), 1815 + ), 1816 + ("content.url".to_string(), "embed.external.uri".to_string()), 1817 + ]), 1818 + ..Default::default() 1819 + }; 1820 + 1821 + let result = 1822 + transmogrify_with_refs(&from, &to, &record, Some(&mappings), Some(&resolved_refs)) 1823 + .unwrap(); 1824 + let obj = result.record.as_object().unwrap(); 1825 + 1826 + assert_eq!(obj.get("text").unwrap(), "A ZIP code is 5 characters."); 1827 + assert_eq!(obj.get("createdAt").unwrap(), "2026-03-11T18:41:46.651Z"); 1828 + assert!( 1829 + obj.contains_key("embed"), 1830 + "embed field should be present from auto-mapping" 1831 + ); 1832 + 1833 + let embed = obj.get("embed").unwrap().as_object().unwrap(); 1834 + assert_eq!(embed.get("$type").unwrap(), "app.bsky.embed.external"); 1835 + 1836 + let external = embed.get("external").unwrap().as_object().unwrap(); 1837 + assert_eq!(external.get("uri").unwrap(), "https://zipcodefirst.com/"); 1838 + assert_eq!(external.get("title").unwrap(), "Put the ZIP code first."); 1839 + assert_eq!( 1840 + external.get("description").unwrap(), 1841 + "A ZIP code is 5 characters." 1842 + ); 1843 + assert!(external.get("imageUrl").is_none()); 1844 + assert!(external.get("siteName").is_none()); 1845 + } 1846 + 1847 + #[test] 1848 + fn test_mappings_is_empty() { 1849 + let empty = TransmogrifyMappings::default(); 1850 + assert!(empty.is_empty()); 1851 + assert!(empty.field_mappings_ref().is_none()); 1852 + 1853 + let with_mappings = TransmogrifyMappings { 1854 + field_mappings: HashMap::from([("a".to_string(), "b".to_string())]), 1855 + ..Default::default() 1856 + }; 1857 + assert!(!with_mappings.is_empty()); 1858 + assert!(with_mappings.field_mappings_ref().is_some()); 1859 + } 1860 + }
+37 -6
crates/atproto-lexicon/src/validation/schema_file.rs
··· 66 66 message: "lexicon must have at least one definition".to_string(), 67 67 }); 68 68 } 69 - if !self.defs.contains_key("main") { 69 + let mut primary_count = 0; 70 + for (name, def) in &self.defs { 71 + if def.is_primary() { 72 + primary_count += 1; 73 + if name != "main" { 74 + return Err(DataValidationError::SchemaStructureInvalid { 75 + message: format!("primary type definition '{}' must be named 'main'", name), 76 + }); 77 + } 78 + } 79 + } 80 + if primary_count > 1 { 70 81 return Err(DataValidationError::SchemaStructureInvalid { 71 - message: "lexicon must have a 'main' definition".to_string(), 82 + message: "lexicon must have at most one primary type definition".to_string(), 72 83 }); 73 84 } 74 - if let Some(main_def) = self.defs.get("main") 75 - && !main_def.is_primary() 76 - {} 77 85 for (name, _def) in &self.defs { 78 86 validate_def_name(name)?; 79 87 } ··· 313 321 } 314 322 315 323 #[test] 316 - fn test_missing_main() { 324 + fn test_lexicon_without_main() { 317 325 let json = 318 326 r#"{"lexicon": 1, "id": "com.example.test", "defs": {"other": {"type": "token"}}}"#; 319 327 let result = SchemaFile::parse(json); 328 + assert!(result.is_ok()); 329 + assert!(result.unwrap().main().is_none()); 330 + } 331 + 332 + #[test] 333 + fn test_defs_only_lexicon() { 334 + let json = r#"{"lexicon": 1, "id": "com.example.defs", "defs": {"viewBasic": {"type": "object", "properties": {"name": {"type": "string"}}}, "viewDetailed": {"type": "object", "properties": {"name": {"type": "string"}, "bio": {"type": "string"}}}}}"#; 335 + let schema = SchemaFile::parse(json).unwrap(); 336 + assert!(schema.main().is_none()); 337 + assert!(schema.get_def("viewBasic").is_some()); 338 + assert!(schema.get_def("viewDetailed").is_some()); 339 + } 340 + 341 + #[test] 342 + fn test_primary_type_must_be_named_main() { 343 + let json = r#"{"lexicon": 1, "id": "com.example.test", "defs": {"other": {"type": "record", "record": {"type": "object", "properties": {"name": {"type": "string"}}}}}}"#; 344 + let result = SchemaFile::parse(json); 320 345 assert!(result.is_err()); 346 + assert!( 347 + result 348 + .unwrap_err() 349 + .to_string() 350 + .contains("must be named 'main'") 351 + ); 321 352 } 322 353 323 354 #[test]
+1 -1
crates/atproto-oauth-aip/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-aip" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "ATProtocol AIP OAuth tools" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-axum" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "Axum web framework integration for AT Protocol OAuth workflows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "OAuth workflow implementation for AT Protocol - PKCE, DPoP, and secure authentication flows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-record/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-record" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol record signature operations - cryptographic signing and verification for AT Protocol records" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-repo/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-repo" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol repository handling - CAR v1 serialization and Merkle Search Tree operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-tap/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-tap" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-xrpcs-helloworld/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs-helloworld" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "Complete example implementation of an AT Protocol XRPC service with DID web functionality and JWT authentication" 5 5 edition.workspace = true 6 6 rust-version.workspace = true
+1 -1
crates/atproto-xrpcs/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "Core building blocks for implementing AT Protocol XRPC services with JWT authorization" 5 5 edition.workspace = true 6 6 rust-version.workspace = true
+1 -1
crates/atpxrpc/Cargo.toml
··· 1 1 [package] 2 2 name = "atpxrpc" 3 - version = "0.14.3" 3 + version = "0.14.4" 4 4 description = "AT Protocol XRPC client with persistent session management" 5 5 6 6 edition.workspace = true