hecks 3.13.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
  7. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  8. data/examples/pizzas/config.ru +22 -0
  9. data/lib/hecks/adapters/driven/prism.rb +0 -2
  10. data/lib/hecks/adapters/driven.rb +0 -2
  11. data/lib/hecks/adapters/driving/json.rb +1 -7
  12. data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
  13. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  14. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  15. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  16. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  17. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  18. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  19. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  20. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  21. data/lib/hecks/bluebook/named_pattern.rb +33 -0
  22. data/lib/hecks/bluebook/translation.rb +18 -10
  23. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  24. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  25. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  26. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  27. data/lib/hecks/bluebook.rb +3 -0
  28. data/lib/hecks/chapters.rb +3 -3
  29. data/lib/hecks/cli/domain_stub.rb +1 -1
  30. data/lib/hecks/cli/interview_aggregate.rb +4 -4
  31. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  32. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  33. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  34. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  35. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  36. data/lib/hecks/composer/adapters/jev.rb +66 -0
  37. data/lib/hecks/composer/agent.rb +113 -0
  38. data/lib/hecks/composer/assistant.rb +110 -0
  39. data/lib/hecks/composer/behavior_run.rb +53 -0
  40. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  41. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  42. data/lib/hecks/composer/board.rb +65 -0
  43. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  44. data/lib/hecks/composer/board_edit/source.rb +84 -0
  45. data/lib/hecks/composer/board_edit.rb +144 -0
  46. data/lib/hecks/composer/check.rb +89 -0
  47. data/lib/hecks/composer/domains.rb +81 -0
  48. data/lib/hecks/composer/jobs.rb +134 -0
  49. data/lib/hecks/composer/layout.rb +37 -0
  50. data/lib/hecks/composer/live.rb +150 -0
  51. data/lib/hecks/composer/outline.rb +48 -0
  52. data/lib/hecks/composer/page.html +989 -0
  53. data/lib/hecks/composer/panels.rb +142 -0
  54. data/lib/hecks/composer/parse.rb +83 -0
  55. data/lib/hecks/composer/plain.rb +28 -0
  56. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  57. data/lib/hecks/composer/session.rb +141 -0
  58. data/lib/hecks/composer/toolbox.rb +166 -0
  59. data/lib/hecks/composer/web.rb +156 -0
  60. data/lib/hecks/composer/workbench.rb +51 -0
  61. data/lib/hecks/composer/workspace.rb +81 -0
  62. data/lib/hecks/composer.rb +17 -0
  63. data/lib/hecks/corpus/adapters.rb +22 -0
  64. data/lib/hecks/corpus/rust.rb +1 -1
  65. data/lib/hecks/corpus/worlds.rb +19 -0
  66. data/lib/hecks/corpus.rb +5 -0
  67. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
  68. data/lib/hecks/forms/app/commands.rb +2 -3
  69. data/lib/hecks/forms/app/queries.rb +2 -3
  70. data/lib/hecks/forms/app/records.rb +17 -5
  71. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  72. data/lib/hecks/forms/field_controls.rb +2 -2
  73. data/lib/hecks/forms/field_renderer.rb +1 -1
  74. data/lib/hecks/forms/field_shape.rb +36 -17
  75. data/lib/hecks/forms/index_renderer.rb +2 -1
  76. data/lib/hecks/forms/page.rb +1 -1
  77. data/lib/hecks/forms/params.rb +0 -13
  78. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  79. data/lib/hecks/forms/record_renderer.rb +11 -7
  80. data/lib/hecks/forms/rule_words.rb +100 -0
  81. data/lib/hecks/forms/value_object_shape.rb +2 -1
  82. data/lib/hecks/framework/bluebook/change.bluebook +2 -2
  83. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  84. data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
  85. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  86. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  87. data/lib/hecks/gate/stages.yml +25 -0
  88. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  89. data/lib/hecks/grammar/evolve.rb +0 -3
  90. data/lib/hecks/grammar/translation.bluebook +1 -1
  91. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  92. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  93. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  94. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  95. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  96. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  97. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  98. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  99. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  100. data/lib/hecks/hecks/codebase.behaviors +6 -0
  101. data/lib/hecks/hecks/codebase.bluebook +58 -37
  102. data/lib/hecks/hecks/composer.behaviors +68 -0
  103. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  104. data/lib/hecks/hecks/custodian.behaviors +0 -6
  105. data/lib/hecks/hecks/custodian.bluebook +15 -92
  106. data/lib/hecks/hecks/hecks.bluebook +2 -2
  107. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  108. data/lib/hecks/hecks/hecks.world +1 -1
  109. data/lib/hecks/hecks/rehearsal.bluebook +10 -16
  110. data/lib/hecks/hecks/tooling.bluebook +22 -22
  111. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  112. data/lib/hecks/kernel_capabilities.rb +1 -1
  113. data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
  114. data/lib/hecks/language/bluebook/command.bluebook +1 -0
  115. data/lib/hecks/language/bluebook/entity.bluebook +1 -0
  116. data/lib/hecks/language/bluebook/query.bluebook +1 -0
  117. data/lib/hecks/language/bluebook/shape.bluebook +8 -7
  118. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  119. data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
  120. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  121. data/lib/hecks/ports/access_control.rb +2 -2
  122. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  123. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  124. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  125. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  126. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  127. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  128. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  129. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  130. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  131. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  132. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  133. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  134. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  135. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  136. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  137. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  138. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  139. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  140. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  141. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  142. data/lib/hecks/ports.rb +0 -1
  143. data/lib/hecks/projection_files.rb +3 -1
  144. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  145. data/lib/hecks/projections/glossary/entries.rb +10 -4
  146. data/lib/hecks/projections/glossary/groups.rb +42 -0
  147. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  148. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  149. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  150. data/lib/hecks/projections/glossary/html.rb +1 -2
  151. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  152. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  153. data/lib/hecks/projections/glossary/page.css +143 -153
  154. data/lib/hecks/projections/glossary/page.js +63 -3
  155. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  156. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  157. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  158. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  159. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  160. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  161. data/lib/hecks/projections/glossary.rb +2 -1
  162. data/lib/hecks/projections/plain_english.rb +97 -0
  163. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  164. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  165. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  166. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  167. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  168. data/lib/hecks/projections/statements/actions.rb +99 -0
  169. data/lib/hecks/projections/statements/relationships.rb +86 -0
  170. data/lib/hecks/projections/statements.rb +15 -24
  171. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  172. data/lib/hecks/projector/exporter/translations.rb +18 -35
  173. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  174. data/lib/hecks/projector/exporter.rb +5 -1
  175. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  176. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  177. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  178. data/lib/hecks/quality_control/quality_control.bluebook +51 -51
  179. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  180. data/lib/hecks/runtime/outbox.rb +2 -9
  181. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  182. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  183. data/lib/hecks/runtime/value.rb +13 -0
  184. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  185. data/lib/hecks/rust_build/wasm.rb +0 -12
  186. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  187. data/lib/hecks/rust_format.rb +77 -0
  188. data/lib/hecks/site/bluebook/site.bluebook +5 -5
  189. data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
  190. data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
  191. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  192. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  193. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  194. data/lib/hecks/tools/translation_audit.rb +8 -10
  195. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  196. data/lib/hecks/version.rb +1 -1
  197. data/lib/hecks/vocabulary.rb +2 -1
  198. data/lib/hecks.rb +3 -0
  199. data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
  200. data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
  201. data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
  202. data/rust/Cargo.toml +40 -0
  203. data/rust/build/Cargo.toml +36 -0
  204. data/rust/build/clippy.toml +4 -0
  205. data/rust/build/src/adapters_pass.rs +196 -0
  206. data/rust/build/src/binding_pass.rs +54 -13
  207. data/rust/build/src/build_artifact.rs +23 -5
  208. data/rust/build/src/cargo_sync.rs +69 -26
  209. data/rust/build/src/lineage_pass.rs +101 -87
  210. data/rust/build/src/main.rs +11 -2
  211. data/rust/build/src/pipeline.rs +80 -19
  212. data/rust/build/src/reserved_names.rs +6 -50
  213. data/rust/build/src/resolve.rs +82 -35
  214. data/rust/build/src/rustfmt.rs +100 -0
  215. data/rust/build/src/seams_pass.rs +85 -16
  216. data/rust/build/src/subprocess.rs +30 -6
  217. data/rust/build/src/tmp.rs +5 -2
  218. data/rust/build/src/translation_pass.rs +72 -22
  219. data/rust/build/src/world_pass.rs +109 -0
  220. data/rust/clippy.toml +4 -0
  221. data/rust/codegen/Cargo.toml +36 -0
  222. data/rust/codegen/clippy.toml +4 -0
  223. data/rust/codegen/src/asks.rs +40 -14
  224. data/rust/codegen/src/attr.rs +2 -2
  225. data/rust/codegen/src/bridging.rs +676 -117
  226. data/rust/codegen/src/commands.rs +979 -373
  227. data/rust/codegen/src/constraints.rs +88 -25
  228. data/rust/codegen/src/dependency_planning.rs +128 -34
  229. data/rust/codegen/src/domain_generator.rs +300 -170
  230. data/rust/codegen/src/exemplar.rs +87 -32
  231. data/rust/codegen/src/expr_emitter.rs +54 -6
  232. data/rust/codegen/src/fielded.rs +193 -41
  233. data/rust/codegen/src/hecks_naming.rs +124 -16
  234. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  235. data/rust/codegen/src/json.rs +80 -44
  236. data/rust/codegen/src/json_codec.rs +585 -149
  237. data/rust/codegen/src/literal.rs +104 -138
  238. data/rust/codegen/src/main.rs +145 -52
  239. data/rust/codegen/src/manifest.rs +76 -14
  240. data/rust/codegen/src/mutations.rs +1212 -239
  241. data/rust/codegen/src/naming.rs +111 -60
  242. data/rust/codegen/src/optional_pass.rs +90 -28
  243. data/rust/codegen/src/ports.rs +5 -4
  244. data/rust/codegen/src/prelude.rs +139 -49
  245. data/rust/codegen/src/queries.rs +601 -226
  246. data/rust/codegen/src/reactions.rs +268 -74
  247. data/rust/codegen/src/read_models.rs +606 -205
  248. data/rust/codegen/src/reference_specs.rs +16 -3
  249. data/rust/codegen/src/registry.rs +128 -79
  250. data/rust/codegen/src/reserved_names.rs +6 -50
  251. data/rust/codegen/src/shared.rs +71 -22
  252. data/rust/codegen/src/sidecars.rs +15 -5
  253. data/rust/codegen/src/skip_reason.rs +8 -2
  254. data/rust/codegen/src/types.rs +306 -108
  255. data/rust/host/Cargo.lock +1 -0
  256. data/rust/host/Cargo.toml +40 -4
  257. data/rust/host/HECKS_RELEASE +1 -1
  258. data/rust/host/clippy.toml +4 -0
  259. data/rust/host/src/api.rs +698 -218
  260. data/rust/host/src/auth.rs +491 -214
  261. data/rust/host/src/b64.rs +91 -0
  262. data/rust/host/src/bin/expr_harness.rs +6 -2
  263. data/rust/host/src/bin/lineage_harness.rs +47 -11
  264. data/rust/host/src/bin/mint_harness.rs +70 -37
  265. data/rust/host/src/bin/validate_harness.rs +16 -8
  266. data/rust/host/src/boot.rs +144 -68
  267. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  268. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  269. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  270. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  271. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  272. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  273. data/rust/host/src/checkout.rs +78 -32
  274. data/rust/host/src/clock.rs +8 -0
  275. data/rust/host/src/commerce_ir.rs +333 -95
  276. data/rust/host/src/crypto.rs +33 -0
  277. data/rust/host/src/dispatch.rs +990 -331
  278. data/rust/host/src/expr_json.rs +1021 -273
  279. data/rust/host/src/extension.rs +24 -4
  280. data/rust/host/src/field_hints.rs +11 -7
  281. data/rust/host/src/fuzz_support.rs +85 -13
  282. data/rust/host/src/host.rs +100 -0
  283. data/rust/host/src/http.rs +22 -0
  284. data/rust/host/src/ir.rs +326 -16
  285. data/rust/host/src/journal.rs +277 -176
  286. data/rust/host/src/lambda_client.rs +219 -53
  287. data/rust/host/src/lib.rs +16 -4
  288. data/rust/host/src/log.rs +62 -11
  289. data/rust/host/src/mint.rs +1153 -326
  290. data/rust/host/src/needs.rs +185 -44
  291. data/rust/host/src/payments/keystore.rs +197 -45
  292. data/rust/host/src/payments/roles.rs +191 -0
  293. data/rust/host/src/payments/tests.rs +1445 -275
  294. data/rust/host/src/payments.rs +409 -121
  295. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  296. data/rust/host/src/platform_signin/config.rs +11 -11
  297. data/rust/host/src/platform_signin/cookie.rs +2 -2
  298. data/rust/host/src/platform_signin/extension.rs +2 -1
  299. data/rust/host/src/platform_signin/handler.rs +9 -8
  300. data/rust/host/src/platform_signin/jti.rs +4 -2
  301. data/rust/host/src/platform_signin/jwks.rs +2 -1
  302. data/rust/host/src/platform_signin/jws.rs +2 -37
  303. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  304. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  305. data/rust/host/src/platform_signin/pages.rs +20 -20
  306. data/rust/host/src/platform_signin/session.rs +8 -1
  307. data/rust/host/src/platform_signin/settings.rs +6 -6
  308. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  309. data/rust/host/src/platform_signin/verify.rs +41 -41
  310. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  311. data/rust/host/src/presentation.rs +207 -63
  312. data/rust/host/src/presentation_write.rs +502 -187
  313. data/rust/host/src/query_step.rs +69 -18
  314. data/rust/host/src/rate_limit.rs +541 -142
  315. data/rust/host/src/reference_transform.rs +77 -25
  316. data/rust/host/src/reference_validate.rs +443 -104
  317. data/rust/host/src/resend.rs +190 -55
  318. data/rust/host/src/secrets.rs +116 -21
  319. data/rust/host/src/server.rs +483 -104
  320. data/rust/host/src/storage_shape.rs +88 -21
  321. data/rust/host/src/test_pg.rs +17 -7
  322. data/rust/host/src/ui_schema.rs +495 -207
  323. data/rust/host/src/wasm_runner.rs +21 -6
  324. data/rust/host/src/web/commerce.rs +101 -23
  325. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  326. data/rust/host/src/web/newsletter/template.rs +26 -7
  327. data/rust/host/src/web/newsletter/tests.rs +112 -23
  328. data/rust/host/src/web/newsletter.rs +209 -59
  329. data/rust/host/src/web/newsletter_send.rs +251 -79
  330. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  331. data/rust/host/src/web/registration_receipt.rs +100 -24
  332. data/rust/host/src/web/registrations.rs +1384 -332
  333. data/rust/host/src/web/resend_webhook.rs +138 -80
  334. data/rust/host/src/web.rs +2416 -508
  335. data/rust/lsp/Cargo.toml +36 -0
  336. data/rust/lsp/clippy.toml +4 -0
  337. data/rust/lsp/src/json.rs +29 -22
  338. data/rust/lsp/src/main.rs +81 -31
  339. data/rust/lsp/src/outline.rs +23 -7
  340. data/rust/lsp/src/rpc.rs +17 -4
  341. data/rust/parser/Cargo.toml +36 -0
  342. data/rust/parser/clippy.toml +4 -0
  343. data/rust/parser/src/build/closed_sets.rs +6 -1
  344. data/rust/parser/src/build/identity.rs +4 -3
  345. data/rust/parser/src/build/mod.rs +1 -0
  346. data/rust/parser/src/build/named_pattern.rs +26 -0
  347. data/rust/parser/src/build/naming.rs +2 -80
  348. data/rust/parser/src/build/pattern_subset.rs +6 -8
  349. data/rust/parser/src/build/query_derive.rs +3 -1
  350. data/rust/parser/src/build/query_inference.rs +14 -5
  351. data/rust/parser/src/build/read_model.rs +4 -1
  352. data/rust/parser/src/build/references.rs +4 -3
  353. data/rust/parser/src/canonical.rs +34 -15
  354. data/rust/parser/src/emit.rs +187 -105
  355. data/rust/parser/src/expr/ast_json.rs +134 -42
  356. data/rust/parser/src/expr/evaluator.rs +27 -9
  357. data/rust/parser/src/expr/mod.rs +54 -13
  358. data/rust/parser/src/expr/resolver.rs +299 -92
  359. data/rust/parser/src/ir.rs +35 -3
  360. data/rust/parser/src/keywords.rs +7168 -510
  361. data/rust/parser/src/lex.rs +11 -25
  362. data/rust/parser/src/lib.rs +1 -0
  363. data/rust/parser/src/main.rs +65 -6
  364. data/rust/parser/src/parse/adapter.rs +157 -0
  365. data/rust/parser/src/parse/aggregate.rs +19 -10
  366. data/rust/parser/src/parse/chapter.rs +16 -17
  367. data/rust/parser/src/parse/command.rs +35 -16
  368. data/rust/parser/src/parse/domain_port.rs +4 -7
  369. data/rust/parser/src/parse/entity.rs +21 -11
  370. data/rust/parser/src/parse/hecksagon.rs +10 -4
  371. data/rust/parser/src/parse/lifecycle.rs +9 -5
  372. data/rust/parser/src/parse/mod.rs +43 -91
  373. data/rust/parser/src/parse/needs.rs +21 -12
  374. data/rust/parser/src/parse/policy.rs +2 -2
  375. data/rust/parser/src/parse/process_manager.rs +9 -11
  376. data/rust/parser/src/parse/query.rs +8 -4
  377. data/rust/parser/src/parse/read_model.rs +57 -16
  378. data/rust/parser/src/parse/value_object.rs +7 -2
  379. data/rust/parser/src/parse/world.rs +322 -0
  380. data/rust/parser/src/ruby_value.rs +7 -6
  381. data/rust/src/exemplar/json.rs +4 -4
  382. data/rust/src/exemplar/mutations.rs +18 -0
  383. data/rust/src/exemplar/read_models.rs +1 -10
  384. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  385. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  386. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  387. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  388. data/rust/src/kernel/cli.rs +229 -112
  389. data/rust/src/kernel/dispatch.rs +323 -115
  390. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  391. data/rust/src/kernel/expr.rs +203 -86
  392. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  393. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  394. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  395. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  396. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  397. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  398. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  399. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  400. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  401. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  402. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  403. data/rust/src/kernel/expression_operators/text.rs +120 -27
  404. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  405. data/rust/src/kernel/json.rs +230 -148
  406. data/rust/src/kernel/mod.rs +46 -33
  407. data/rust/src/kernel/named_query.rs +63 -15
  408. data/rust/src/kernel/needs.rs +112 -25
  409. data/rust/src/kernel/orchestrate.rs +980 -581
  410. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  411. data/rust/src/kernel/pattern.rs +107 -34
  412. data/rust/src/kernel/query_comparators.rs +118 -41
  413. data/rust/src/kernel/query_ordering.rs +13 -5
  414. data/rust/src/kernel/read_model.rs +479 -181
  415. data/rust/src/kernel/reference_lookup.rs +122 -26
  416. data/rust/src/kernel/repository.rs +293 -66
  417. data/rust/src/kernel/routing.rs +58 -42
  418. data/rust/src/kernel/ruby_float.rs +139 -0
  419. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  420. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  421. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  422. data/rust/src/kernel/vocab/mod.rs +6 -6
  423. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  424. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  425. data/rust/src/lib.rs +50 -0
  426. data/rust/src/main.rs +4 -1
  427. data/rust/web/Cargo.toml +36 -0
  428. data/rust/web/clippy.toml +4 -0
  429. metadata +85 -15
  430. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  431. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  432. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  433. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  434. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  435. data/lib/hecks/ports/authentication.port +0 -6
  436. data/lib/hecks/ports/authentication.rb +0 -67
  437. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  438. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  439. data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
  440. data/rust/host/src/approval.rs +0 -652
@@ -1,5 +1,5 @@
1
- //! Sends one newsletter email through Resend's REST API. The Ruby adapter answers the
2
- //! same `deliver` question, so the send loop never knows which provider is live.
1
+ //! Sends one newsletter email through Resend's REST API. The Rust host is the only
2
+ //! implementation of this `deliver` answer; there is no Ruby adapter or mock beside it.
3
3
 
4
4
  use serde_json::{json, Value};
5
5
  use std::time::Duration;
@@ -10,7 +10,7 @@ const MAX_ATTEMPTS: u32 = 4;
10
10
  const DEFAULT_RETRY_SECS: u64 = 1;
11
11
  const MAX_RETRY_SECS: u64 = 5;
12
12
  /// The address the mock refuses, and (in `mock_deliver`) the reason it gives. Adapter-side test
13
- /// vocabulary: the domain names neither, and the Ruby mock keeps its own copy (ADR 0099).
13
+ /// vocabulary: the domain names neither (ADR 0099).
14
14
  const MOCK_BOUNCE_ADDRESS: &str = "bounce@example.com";
15
15
 
16
16
  /// One email to one recipient.
@@ -26,7 +26,7 @@ pub struct Email<'a> {
26
26
 
27
27
  /// What handing an email to the provider came to. A refusal or outage for one
28
28
  /// recipient is `ok: false` with a reason (never an `Err`), so a fan-out can carry on.
29
- #[derive(Debug, PartialEq)]
29
+ #[derive(Debug, PartialEq, Eq)]
30
30
  pub struct Delivery {
31
31
  pub ok: bool,
32
32
  pub message_id: Option<String>,
@@ -34,31 +34,49 @@ pub struct Delivery {
34
34
  }
35
35
 
36
36
  impl Delivery {
37
- fn sent(message_id: String) -> Self {
38
- Delivery { ok: true, message_id: Some(message_id), reason: None }
37
+ const fn sent(message_id: String) -> Self {
38
+ Self {
39
+ ok: true,
40
+ message_id: Some(message_id),
41
+ reason: None,
42
+ }
39
43
  }
40
44
 
41
45
  fn failed(reason: impl Into<String>) -> Self {
42
- Delivery { ok: false, message_id: None, reason: Some(reason.into()) }
46
+ Self {
47
+ ok: false,
48
+ message_id: None,
49
+ reason: Some(reason.into()),
50
+ }
43
51
  }
44
52
  }
45
53
 
46
54
  /// How email leaves the host: really, through Resend, or logged and dropped.
47
55
  pub enum Mailer {
48
- Live { http: reqwest::Client, api_key: String, from: String, reply_to: Option<String>, base_url: String },
56
+ Live {
57
+ http: reqwest::Client,
58
+ api_key: String,
59
+ from: String,
60
+ reply_to: Option<String>,
61
+ base_url: String,
62
+ },
49
63
  Mock,
50
64
  }
51
65
 
52
66
  impl Mailer {
53
67
  /// `Ok(None)` when nothing is configured; `Err` when a key is set without `RESEND_FROM` —
54
68
  /// marking an issue sent is irreversible, so a missing key must refuse before that happens.
55
- pub fn from_env() -> Result<Option<Mailer>, String> {
56
- let var = |name: &str| std::env::var(name).unwrap_or_default();
57
- Self::configured(&var("RESEND_API_KEY"), &var("RESEND_FROM"), &var("RESEND_MOCK"))
58
- .map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
69
+ pub fn from_env() -> Result<Option<Self>, String> {
70
+ let var = |name: &str| crate::secrets::var(name).unwrap_or_default();
71
+ Self::configured(
72
+ &var("RESEND_API_KEY"),
73
+ &var("RESEND_FROM"),
74
+ &var("RESEND_MOCK"),
75
+ )
76
+ .map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
59
77
  }
60
78
 
61
- fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Mailer>, String> {
79
+ fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Self>, String> {
62
80
  if !api_key.is_empty() {
63
81
  if from.is_empty() {
64
82
  return Err("RESEND_API_KEY is set but RESEND_FROM is not — Resend needs a from address on a verified domain".to_string());
@@ -66,36 +84,64 @@ impl Mailer {
66
84
  return Ok(Some(Self::live(api_key, from, API_BASE)));
67
85
  }
68
86
  // Mock requires an explicit opt-in, unlike checkout's mock-by-default.
69
- Ok((mock == "1").then_some(Mailer::Mock))
87
+ Ok((mock == "1").then_some(Self::Mock))
70
88
  }
71
89
 
72
90
  /// A mailer that posts to `base_url` (Resend's API, or a stand-in in tests).
73
- pub fn live(api_key: &str, from: &str, base_url: &str) -> Mailer {
74
- let http = reqwest::Client::builder().timeout(TIMEOUT).build().expect("a reqwest client with a timeout always builds");
75
- Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), reply_to: None, base_url: base_url.to_string() }
91
+ #[allow(clippy::expect_used)] // reason: startup config; a client with only a timeout set always builds
92
+ pub fn live(api_key: &str, from: &str, base_url: &str) -> Self {
93
+ let http = reqwest::Client::builder()
94
+ .timeout(TIMEOUT)
95
+ .build()
96
+ .expect("a reqwest client with a timeout always builds");
97
+ Self::Live {
98
+ http,
99
+ api_key: api_key.to_string(),
100
+ from: from.to_string(),
101
+ reply_to: None,
102
+ base_url: base_url.to_string(),
103
+ }
76
104
  }
77
105
 
78
106
  /// Sets where replies go (`Reply-To`), when the address people reply to is not the one the mail
79
107
  /// is sent from. Blank leaves replies going to the From address. A mock mailer has no replies.
80
- pub fn replying_to(self, reply_to: &str) -> Mailer {
108
+ pub fn replying_to(self, reply_to: &str) -> Self {
81
109
  match self {
82
- Mailer::Live { http, api_key, from, base_url, .. } => {
110
+ Self::Live {
111
+ http,
112
+ api_key,
113
+ from,
114
+ base_url,
115
+ ..
116
+ } => {
83
117
  let reply_to = (!reply_to.trim().is_empty()).then(|| reply_to.trim().to_string());
84
- Mailer::Live { http, api_key, from, reply_to, base_url }
118
+ Self::Live {
119
+ http,
120
+ api_key,
121
+ from,
122
+ reply_to,
123
+ base_url,
124
+ }
85
125
  }
86
- mock => mock,
126
+ mock @ Self::Mock => mock,
87
127
  }
88
128
  }
89
129
 
90
130
  #[cfg(test)]
91
- pub fn is_mock(&self) -> bool {
92
- matches!(self, Mailer::Mock)
131
+ pub const fn is_mock(&self) -> bool {
132
+ matches!(self, Self::Mock)
93
133
  }
94
134
 
95
135
  pub async fn deliver(&self, email: &Email<'_>) -> Delivery {
96
136
  match self {
97
- Mailer::Mock => mock_deliver(email),
98
- Mailer::Live { http, api_key, from, reply_to, base_url } => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
137
+ Self::Mock => mock_deliver(email),
138
+ Self::Live {
139
+ http,
140
+ api_key,
141
+ from,
142
+ reply_to,
143
+ base_url,
144
+ } => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
99
145
  }
100
146
  }
101
147
  }
@@ -104,7 +150,10 @@ fn mock_deliver(email: &Email<'_>) -> Delivery {
104
150
  if email.to == MOCK_BOUNCE_ADDRESS {
105
151
  return Delivery::failed("bounced");
106
152
  }
107
- println!("[mock resend] to={} subject={:?}\n{}", email.to, email.subject, email.body);
153
+ println!(
154
+ "[mock resend] to={} subject={:?}\n{}",
155
+ email.to, email.subject, email.body
156
+ );
108
157
  Delivery::sent(format!("re_{}", uuid::Uuid::new_v4().simple()))
109
158
  }
110
159
 
@@ -113,7 +162,11 @@ fn payload(from: &str, reply_to: Option<&str>, email: &Email<'_>) -> Value {
113
162
  if let Some(reply_to) = reply_to {
114
163
  payload["reply_to"] = json!(reply_to);
115
164
  }
116
- let content_key = if email.body.trim_start().starts_with('<') { "html" } else { "text" };
165
+ let content_key = if email.body.trim_start().starts_with('<') {
166
+ "html"
167
+ } else {
168
+ "text"
169
+ };
117
170
  payload[content_key] = json!(email.body);
118
171
  if let Some(url) = email.unsubscribe_url {
119
172
  payload["headers"] = json!({ "List-Unsubscribe": format!("<{url}>") });
@@ -131,10 +184,23 @@ fn retry_delay(response: &reqwest::Response) -> Duration {
131
184
  Duration::from_secs(secs.min(MAX_RETRY_SECS))
132
185
  }
133
186
 
134
- async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_to: Option<&str>, base_url: &str, email: &Email<'_>) -> Delivery {
187
+ async fn live_deliver(
188
+ http: &reqwest::Client,
189
+ api_key: &str,
190
+ from: &str,
191
+ reply_to: Option<&str>,
192
+ base_url: &str,
193
+ email: &Email<'_>,
194
+ ) -> Delivery {
135
195
  let body = payload(from, reply_to, email);
136
196
  for attempt in 1..=MAX_ATTEMPTS {
137
- let response = match http.post(format!("{base_url}/emails")).bearer_auth(api_key).json(&body).send().await {
197
+ let response = match http
198
+ .post(format!("{base_url}/emails"))
199
+ .bearer_auth(api_key)
200
+ .json(&body)
201
+ .send()
202
+ .await
203
+ {
138
204
  Ok(r) => r,
139
205
  Err(e) => return Delivery::failed(format!("resend unreachable: {}", e.without_url())),
140
206
  };
@@ -145,12 +211,15 @@ async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_t
145
211
  }
146
212
  let answer: Value = response.json().await.unwrap_or(Value::Null);
147
213
  if status.is_success() {
148
- return match answer.get("id").and_then(|v| v.as_str()) {
149
- Some(id) => Delivery::sent(id.to_string()),
150
- None => Delivery::failed("resend accepted the email but returned no id"),
151
- };
214
+ return answer.get("id").and_then(|v| v.as_str()).map_or_else(
215
+ || Delivery::failed("resend accepted the email but returned no id"),
216
+ |id| Delivery::sent(id.to_string()),
217
+ );
152
218
  }
153
- let message = answer.get("message").and_then(|v| v.as_str()).map(String::from);
219
+ let message = answer
220
+ .get("message")
221
+ .and_then(|v| v.as_str())
222
+ .map(String::from);
154
223
  return Delivery::failed(message.unwrap_or_else(|| format!("resend answered {status}")));
155
224
  }
156
225
  Delivery::failed("resend kept rate limiting this email")
@@ -185,7 +254,7 @@ mod tests {
185
254
  stream.write_all(response.as_bytes()).unwrap();
186
255
  }
187
256
  });
188
- FakeResend { base, requests }
257
+ Self { base, requests }
189
258
  }
190
259
 
191
260
  fn requests(&self) -> Vec<String> {
@@ -203,7 +272,11 @@ mod tests {
203
272
  if let Some((head, body)) = text.split_once("\r\n\r\n") {
204
273
  let length = head
205
274
  .lines()
206
- .find_map(|l| l.to_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse::<usize>().ok()))
275
+ .find_map(|l| {
276
+ l.to_lowercase()
277
+ .strip_prefix("content-length:")
278
+ .and_then(|v| v.trim().parse::<usize>().ok())
279
+ })
207
280
  .unwrap_or(0);
208
281
  if body.len() >= length {
209
282
  return text;
@@ -216,7 +289,14 @@ mod tests {
216
289
  }
217
290
 
218
291
  fn email<'a>(to: &'a str, body: &'a str) -> Email<'a> {
219
- Email { to, subject: "Spring news", body, unsubscribe_url: Some("https://example.com/newsletter-unsubscribed.html?email=a%40b.com") }
292
+ Email {
293
+ to,
294
+ subject: "Spring news",
295
+ body,
296
+ unsubscribe_url: Some(
297
+ "https://example.com/newsletter-unsubscribed.html?email=a%40b.com",
298
+ ),
299
+ }
220
300
  }
221
301
 
222
302
  #[test]
@@ -231,7 +311,9 @@ mod tests {
231
311
 
232
312
  #[test]
233
313
  fn a_key_and_from_is_live_even_when_mock_is_also_set() {
234
- let mailer = Mailer::configured("re_x", "News <n@mail.example.com>", "1").unwrap().unwrap();
314
+ let mailer = Mailer::configured("re_x", "News <n@mail.example.com>", "1")
315
+ .unwrap()
316
+ .unwrap();
235
317
  assert!(!mailer.is_mock());
236
318
  }
237
319
 
@@ -243,7 +325,9 @@ mod tests {
243
325
 
244
326
  #[test]
245
327
  fn an_html_body_goes_as_html_and_a_plain_one_as_text() {
246
- assert!(payload("f", None, &email("a@b.com", " <p>hi</p>")).get("html").is_some());
328
+ assert!(payload("f", None, &email("a@b.com", " <p>hi</p>"))
329
+ .get("html")
330
+ .is_some());
247
331
  let plain = payload("f", None, &email("a@b.com", "just a link"));
248
332
  assert!(plain.get("text").is_some() && plain.get("html").is_none());
249
333
  }
@@ -251,8 +335,20 @@ mod tests {
251
335
  #[test]
252
336
  fn the_unsubscribe_url_rides_as_a_list_unsubscribe_header() {
253
337
  let with = payload("f", None, &email("a@b.com", "x"));
254
- assert_eq!(with["headers"]["List-Unsubscribe"], "<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>");
255
- let without = payload("f", None, &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
338
+ assert_eq!(
339
+ with["headers"]["List-Unsubscribe"],
340
+ "<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>"
341
+ );
342
+ let without = payload(
343
+ "f",
344
+ None,
345
+ &Email {
346
+ to: "a@b.com",
347
+ subject: "s",
348
+ body: "x",
349
+ unsubscribe_url: None,
350
+ },
351
+ );
256
352
  assert!(without.get("headers").is_none());
257
353
  }
258
354
 
@@ -260,33 +356,47 @@ mod tests {
260
356
  fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
261
357
  let with = payload("f", Some("replies@example.com"), &email("a@b.com", "x"));
262
358
  assert_eq!(with["reply_to"], "replies@example.com");
263
- assert!(payload("f", None, &email("a@b.com", "x")).get("reply_to").is_none());
359
+ assert!(payload("f", None, &email("a@b.com", "x"))
360
+ .get("reply_to")
361
+ .is_none());
264
362
  }
265
363
 
266
364
  #[test]
267
365
  fn replying_to_trims_the_address_and_a_blank_one_sets_none() {
268
- let set = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
366
+ let set =
367
+ Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
269
368
  assert!(matches!(&set, Mailer::Live { reply_to: Some(r), .. } if r == "me@example.com"));
270
369
  let blank = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" ");
271
370
  assert!(matches!(&blank, Mailer::Live { reply_to: None, .. }));
272
- assert!(Mailer::Mock.replying_to("me@example.com").is_mock(), "a mock mailer has no replies");
371
+ assert!(
372
+ Mailer::Mock.replying_to("me@example.com").is_mock(),
373
+ "a mock mailer has no replies"
374
+ );
273
375
  }
274
376
 
275
377
  #[tokio::test]
276
378
  async fn a_live_send_carries_the_reply_to_address_to_resend() {
277
379
  let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
278
- let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("replies@example.com");
380
+ let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base)
381
+ .replying_to("replies@example.com");
279
382
 
280
383
  mailer.deliver(&email("a@b.com", "x")).await;
281
384
 
282
- assert!(fake.requests()[0].contains(r#""reply_to":"replies@example.com""#), "{}", fake.requests()[0]);
385
+ assert!(
386
+ fake.requests()[0].contains(r#""reply_to":"replies@example.com""#),
387
+ "{}",
388
+ fake.requests()[0]
389
+ );
283
390
  }
284
391
 
285
392
  #[test]
286
393
  fn the_mock_logs_instead_of_sending_and_bounces_the_test_address() {
287
394
  let sent = mock_deliver(&email("a@b.com", "x"));
288
395
  assert!(sent.ok && sent.message_id.unwrap().starts_with("re_"));
289
- assert_eq!(mock_deliver(&email(MOCK_BOUNCE_ADDRESS, "x")), Delivery::failed("bounced"));
396
+ assert_eq!(
397
+ mock_deliver(&email(MOCK_BOUNCE_ADDRESS, "x")),
398
+ Delivery::failed("bounced")
399
+ );
290
400
  }
291
401
 
292
402
  #[tokio::test]
@@ -301,26 +411,49 @@ mod tests {
301
411
  assert_eq!(requests.len(), 1);
302
412
  let request = requests[0].to_lowercase();
303
413
  assert!(request.starts_with("post /emails "), "{request}");
304
- assert!(request.contains("authorization: bearer re_secret"), "{request}");
305
- assert!(requests[0].contains(r#""to":["a@b.com"]"#) && requests[0].contains(r#""from":"News <n@mail.example.com>""#), "{}", requests[0]);
414
+ assert!(
415
+ request.contains("authorization: bearer re_secret"),
416
+ "{request}"
417
+ );
418
+ assert!(
419
+ requests[0].contains(r#""to":["a@b.com"]"#)
420
+ && requests[0].contains(r#""from":"News <n@mail.example.com>""#),
421
+ "{}",
422
+ requests[0]
423
+ );
306
424
  }
307
425
 
308
426
  #[tokio::test]
309
427
  async fn a_refusal_from_resend_is_a_failed_delivery_carrying_its_message() {
310
- let fake = FakeResend::start(vec![("403 Forbidden", "", r#"{"message":"The mail.example.com domain is not verified.","name":"validation_error"}"#)]);
311
- let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base).deliver(&email("a@b.com", "x")).await;
428
+ let fake = FakeResend::start(vec![(
429
+ "403 Forbidden",
430
+ "",
431
+ r#"{"message":"The mail.example.com domain is not verified.","name":"validation_error"}"#,
432
+ )]);
433
+ let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base)
434
+ .deliver(&email("a@b.com", "x"))
435
+ .await;
312
436
 
313
437
  assert!(!delivery.ok);
314
- assert_eq!(delivery.reason.as_deref(), Some("The mail.example.com domain is not verified."));
438
+ assert_eq!(
439
+ delivery.reason.as_deref(),
440
+ Some("The mail.example.com domain is not verified.")
441
+ );
315
442
  }
316
443
 
317
444
  #[tokio::test]
318
445
  async fn a_rate_limited_send_waits_and_is_retried() {
319
446
  let fake = FakeResend::start(vec![
320
- ("429 Too Many Requests", "retry-after: 0\r\n", r#"{"message":"Too many requests"}"#),
447
+ (
448
+ "429 Too Many Requests",
449
+ "retry-after: 0\r\n",
450
+ r#"{"message":"Too many requests"}"#,
451
+ ),
321
452
  ("200 OK", "", r#"{"id":"em_after_retry"}"#),
322
453
  ]);
323
- let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base).deliver(&email("a@b.com", "x")).await;
454
+ let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base)
455
+ .deliver(&email("a@b.com", "x"))
456
+ .await;
324
457
 
325
458
  assert_eq!(delivery, Delivery::sent("em_after_retry".to_string()));
326
459
  assert_eq!(fake.requests().len(), 2);
@@ -332,7 +465,9 @@ mod tests {
332
465
  let base = format!("http://{}", unused.local_addr().unwrap());
333
466
  drop(unused);
334
467
 
335
- let delivery = Mailer::live("re_x", "n@mail.example.com", &base).deliver(&email("a@b.com", "x")).await;
468
+ let delivery = Mailer::live("re_x", "n@mail.example.com", &base)
469
+ .deliver(&email("a@b.com", "x"))
470
+ .await;
336
471
 
337
472
  assert!(!delivery.ok);
338
473
  assert!(delivery.reason.unwrap().starts_with("resend unreachable"));
@@ -1,6 +1,36 @@
1
1
  //! Fetches secrets from Secrets Manager at runtime, not via CloudFormation dynamic references.
2
2
  //! A resolved reference lands decrypted in function configuration, readable account-wide.
3
3
 
4
+ use std::collections::HashMap;
5
+ use std::sync::{OnceLock, RwLock};
6
+
7
+ // Secrets fetched at boot live here instead of in the process environment: `std::env::set_var` is
8
+ // unsound once tokio's worker and blocking threads exist, since any of them may read the
9
+ // environment (a DNS lookup does) at the same moment.
10
+ static FETCHED: OnceLock<RwLock<HashMap<&'static str, String>>> = OnceLock::new();
11
+
12
+ fn fetched() -> &'static RwLock<HashMap<&'static str, String>> {
13
+ FETCHED.get_or_init(RwLock::default)
14
+ }
15
+
16
+ /// Records a secret fetched at boot under the environment variable name it stands in for.
17
+ pub fn provide(name: &'static str, value: String) {
18
+ fetched()
19
+ .write()
20
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
21
+ .insert(name, value);
22
+ }
23
+
24
+ /// The value of `name`: a secret fetched at boot when there is one, else the process environment.
25
+ pub fn var(name: &str) -> Result<String, std::env::VarError> {
26
+ let provided = fetched()
27
+ .read()
28
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
29
+ .get(name)
30
+ .cloned();
31
+ provided.map_or_else(|| std::env::var(name), Ok)
32
+ }
33
+
4
34
  pub struct AwsSecretFetcher {
5
35
  client: aws_sdk_secretsmanager::Client,
6
36
  }
@@ -10,10 +40,17 @@ impl AwsSecretFetcher {
10
40
  // default features would wire in aws-lc-rs instead (see Cargo.toml).
11
41
  pub async fn from_env() -> Self {
12
42
  let http_client = aws_smithy_http_client::Builder::new()
13
- .tls_provider(aws_smithy_http_client::tls::Provider::Rustls(aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring))
43
+ .tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
44
+ aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring,
45
+ ))
14
46
  .build_https();
15
- let config = aws_config::defaults(aws_config::BehaviorVersion::latest()).http_client(http_client).load().await;
16
- Self { client: aws_sdk_secretsmanager::Client::new(&config) }
47
+ let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
48
+ .http_client(http_client)
49
+ .load()
50
+ .await;
51
+ Self {
52
+ client: aws_sdk_secretsmanager::Client::new(&config),
53
+ }
17
54
  }
18
55
 
19
56
  /// The secret's whole `SecretString`, undecoded. `secret_id` is a full ARN or a name.
@@ -27,17 +64,31 @@ impl AwsSecretFetcher {
27
64
  .map_err(|e| anyhow::anyhow!("fetching secret {secret_id}: {e:?}"))?;
28
65
  response
29
66
  .secret_string()
30
- .map(|s| s.to_string())
67
+ .map(std::string::ToString::to_string)
31
68
  .ok_or_else(|| anyhow::anyhow!("secret {secret_id} has no SecretString"))
32
69
  }
33
70
 
34
71
  /// Like `fetch_secret_string`, but a secret that does not exist yet is
35
72
  /// `None`, not an error: the payment keys' secret is only created by the
36
73
  /// first save.
37
- pub async fn fetch_secret_string_if_present(&self, secret_id: &str) -> anyhow::Result<Option<String>> {
38
- match self.client.get_secret_value().secret_id(secret_id).send().await {
39
- Ok(response) => Ok(response.secret_string().map(|s| s.to_string())),
40
- Err(e) if e.as_service_error().is_some_and(|service| service.is_resource_not_found_exception()) => Ok(None),
74
+ pub async fn fetch_secret_string_if_present(
75
+ &self,
76
+ secret_id: &str,
77
+ ) -> anyhow::Result<Option<String>> {
78
+ match self
79
+ .client
80
+ .get_secret_value()
81
+ .secret_id(secret_id)
82
+ .send()
83
+ .await
84
+ {
85
+ Ok(response) => Ok(response.secret_string().map(std::string::ToString::to_string)),
86
+ Err(e)
87
+ if e.as_service_error()
88
+ .is_some_and(aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueError::is_resource_not_found_exception) =>
89
+ {
90
+ Ok(None)
91
+ }
41
92
  Err(_) => Err(anyhow::anyhow!("reading secret {secret_id} failed")),
42
93
  }
43
94
  }
@@ -45,9 +96,19 @@ impl AwsSecretFetcher {
45
96
  /// Replaces the secret's `SecretString`, creating the secret the first time.
46
97
  /// The errors name the secret and never carry the value.
47
98
  pub async fn put_secret_string(&self, secret_id: &str, value: &str) -> anyhow::Result<()> {
48
- match self.client.put_secret_value().secret_id(secret_id).secret_string(value).send().await {
99
+ match self
100
+ .client
101
+ .put_secret_value()
102
+ .secret_id(secret_id)
103
+ .secret_string(value)
104
+ .send()
105
+ .await
106
+ {
49
107
  Ok(_) => Ok(()),
50
- Err(e) if e.as_service_error().is_some_and(|service| service.is_resource_not_found_exception()) => {
108
+ Err(e)
109
+ if e.as_service_error()
110
+ .is_some_and(aws_sdk_secretsmanager::operation::put_secret_value::PutSecretValueError::is_resource_not_found_exception) =>
111
+ {
51
112
  self.client
52
113
  .create_secret()
53
114
  .name(secret_id)
@@ -66,19 +127,21 @@ impl AwsSecretFetcher {
66
127
  ///
67
128
  /// Pure, so it is tested without a client.
68
129
  pub fn extract_field(secret_json: &str, field: &str) -> Result<String, String> {
69
- let value: serde_json::Value =
70
- serde_json::from_str(secret_json).map_err(|e| format!("secret's SecretString isn't valid JSON: {e}"))?;
130
+ let value: serde_json::Value = serde_json::from_str(secret_json)
131
+ .map_err(|e| format!("secret's SecretString isn't valid JSON: {e}"))?;
71
132
  value
72
133
  .get(field)
73
134
  .and_then(|p| p.as_str())
74
- .map(|s| s.to_string())
135
+ .map(std::string::ToString::to_string)
75
136
  .ok_or_else(|| format!("secret's SecretString has no string {field:?} field"))
76
137
  }
77
138
 
78
139
  /// One named string field out of a secret's JSON `SecretString`, or `None` when the secret does not
79
140
  /// carry it (or is not JSON, or the field is blank). For a field a secret may have and may not.
80
141
  pub fn optional_field(secret_json: &str, field: &str) -> Option<String> {
81
- extract_field(secret_json, field).ok().filter(|value| !value.trim().is_empty())
142
+ extract_field(secret_json, field)
143
+ .ok()
144
+ .filter(|value| !value.trim().is_empty())
82
145
  }
83
146
 
84
147
  /// The `postgres://` URL for a database secret.
@@ -93,9 +156,16 @@ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<Str
93
156
  let user = extract_field(secret_json, "username").unwrap_or_else(|_| "postgres".to_string());
94
157
  let port = serde_json::from_str::<serde_json::Value>(secret_json)
95
158
  .ok()
96
- .and_then(|v| v.get("port").and_then(|p| p.as_u64().or_else(|| p.as_str().and_then(|s| s.parse().ok()))))
159
+ .and_then(|v| {
160
+ v.get("port").and_then(|p| {
161
+ p.as_u64()
162
+ .or_else(|| p.as_str().and_then(|s| s.parse().ok()))
163
+ })
164
+ })
97
165
  .unwrap_or(5432);
98
- Ok(format!("postgres://{user}:{password}@{host}:{port}/{database}"))
166
+ Ok(format!(
167
+ "postgres://{user}:{password}@{host}:{port}/{database}"
168
+ ))
99
169
  }
100
170
 
101
171
  #[cfg(test)]
@@ -105,7 +175,10 @@ mod tests {
105
175
  #[test]
106
176
  fn an_optional_field_is_read_when_present_and_none_when_absent_blank_or_not_json() {
107
177
  let json = r#"{"api_key":"re_1","webhook_secret":"whsec_abc","blank":" "}"#;
108
- assert_eq!(optional_field(json, "webhook_secret").as_deref(), Some("whsec_abc"));
178
+ assert_eq!(
179
+ optional_field(json, "webhook_secret").as_deref(),
180
+ Some("whsec_abc")
181
+ );
109
182
  assert_eq!(optional_field(json, "missing"), None);
110
183
  assert_eq!(optional_field(json, "blank"), None);
111
184
  assert_eq!(optional_field("not json", "webhook_secret"), None);
@@ -114,23 +187,35 @@ mod tests {
114
187
  #[test]
115
188
  fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
116
189
  let json = r#"{"username":"postgres","password":"abc123"}"#;
117
- assert_eq!(database_url(json, "db.example", "app").unwrap(), "postgres://postgres:abc123@db.example:5432/app");
190
+ assert_eq!(
191
+ database_url(json, "db.example", "app").unwrap(),
192
+ "postgres://postgres:abc123@db.example:5432/app"
193
+ );
118
194
  }
119
195
 
120
196
  #[test]
121
197
  fn a_clients_own_secret_logs_in_as_its_role() {
122
198
  let json = r#"{"username":"acme","password":"s3cr3t","host":"db.example","port":5433,"dbname":"acme"}"#;
123
- assert_eq!(database_url(json, "db.example", "acme").unwrap(), "postgres://acme:s3cr3t@db.example:5433/acme");
199
+ assert_eq!(
200
+ database_url(json, "db.example", "acme").unwrap(),
201
+ "postgres://acme:s3cr3t@db.example:5433/acme"
202
+ );
124
203
  }
125
204
 
126
205
  #[test]
127
206
  fn a_secret_with_no_username_is_read_as_postgres() {
128
- assert_eq!(database_url(r#"{"password":"pw"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:5432/d");
207
+ assert_eq!(
208
+ database_url(r#"{"password":"pw"}"#, "h", "d").unwrap(),
209
+ "postgres://postgres:pw@h:5432/d"
210
+ );
129
211
  }
130
212
 
131
213
  #[test]
132
214
  fn a_port_written_as_text_is_read() {
133
- assert_eq!(database_url(r#"{"password":"pw","port":"6000"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:6000/d");
215
+ assert_eq!(
216
+ database_url(r#"{"password":"pw","port":"6000"}"#, "h", "d").unwrap(),
217
+ "postgres://postgres:pw@h:6000/d"
218
+ );
134
219
  }
135
220
 
136
221
  #[test]
@@ -162,4 +247,14 @@ mod tests {
162
247
  fn refuses_invalid_json() {
163
248
  assert!(extract_field("not json", "password").is_err());
164
249
  }
250
+
251
+ #[test]
252
+ fn a_provided_secret_is_read_back_and_an_unprovided_one_falls_back_to_the_environment() {
253
+ provide("HECKS_SECRETS_SPEC_PROVIDED", "from-boot".to_string());
254
+ assert_eq!(
255
+ var("HECKS_SECRETS_SPEC_PROVIDED").as_deref(),
256
+ Ok("from-boot")
257
+ );
258
+ assert!(var("HECKS_SECRETS_SPEC_NEVER_SET").is_err());
259
+ }
165
260
  }