hecks 2.9.0 → 3.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.
- checksums.yaml +4 -4
- data/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +4 -2
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +7 -4
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
// The Rust-native counterpart to `web/presentation_config.rb`'s `.save!`, serving
|
|
2
|
+
// `PUT /api/presentation`: validates against the live domain IR, then dispatches real commands.
|
|
3
|
+
|
|
4
|
+
use crate::dispatch;
|
|
5
|
+
use crate::lambda_client::LambdaInvoker;
|
|
6
|
+
use crate::journal::LineageConfig;
|
|
7
|
+
use crate::presentation::{self, KernelRows};
|
|
8
|
+
use crate::ui_schema;
|
|
9
|
+
use serde_json::{json, Map, Value};
|
|
10
|
+
use std::collections::BTreeSet;
|
|
11
|
+
use std::path::Path;
|
|
12
|
+
use tokio::sync::Mutex;
|
|
13
|
+
use tokio_postgres::Client;
|
|
14
|
+
|
|
15
|
+
// Duplicates the chapter's own `one_of` so this refuses before any dispatch starts.
|
|
16
|
+
const VALID_TONES: &[&str] = &["good", "warn", "danger", "muted", "accent"];
|
|
17
|
+
const VALID_DISPLAYS: &[&str] = &["simple", "prose", "bullet", "mono", "rows"];
|
|
18
|
+
const VALID_FORMATS: &[&str] = &["date", "percent"];
|
|
19
|
+
const VALID_FOLDS: &[&str] = &["count", "money_sum"];
|
|
20
|
+
const VALID_SORTS: &[&str] = &["asc", "desc"];
|
|
21
|
+
|
|
22
|
+
// What a save can answer with other than "saved". `api.rs` maps each variant to a status code,
|
|
23
|
+
// so this module never mentions one.
|
|
24
|
+
pub(crate) enum SaveRefusal {
|
|
25
|
+
// This host's kernel carries no ConsoleSettings chapter.
|
|
26
|
+
NoKernel,
|
|
27
|
+
// `PresentationConfig::Malformed` — `app.rb`'s own 422.
|
|
28
|
+
Malformed(String),
|
|
29
|
+
// A real domain refusal from the kernel, carried whole for `api::domain_refusal`.
|
|
30
|
+
Domain(Value),
|
|
31
|
+
Internal(String),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns the reloaded config, never the submitted one, so a caller sees what was stored.
|
|
35
|
+
pub(crate) async fn save(
|
|
36
|
+
domain_ir: &Value,
|
|
37
|
+
submitted: &Value,
|
|
38
|
+
client: &Mutex<Client>,
|
|
39
|
+
wasm_path: &Path,
|
|
40
|
+
lineage: &LineageConfig,
|
|
41
|
+
invoker: &dyn LambdaInvoker,
|
|
42
|
+
) -> Result<Value, SaveRefusal> {
|
|
43
|
+
// Asked before validated: a host with no such chapter cannot honour this request at all,
|
|
44
|
+
// regardless of which rule the submitted config would otherwise break.
|
|
45
|
+
let existing = match presentation::kernel_rows(client, wasm_path).await {
|
|
46
|
+
Ok(Some(rows)) => rows,
|
|
47
|
+
Ok(None) => return Err(SaveRefusal::NoKernel),
|
|
48
|
+
Err(e) => return Err(SaveRefusal::Internal(format!("{e:#}"))),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
validate(submitted, domain_ir).map_err(SaveRefusal::Malformed)?;
|
|
52
|
+
|
|
53
|
+
let mut writer = Writer { client, wasm_path, lineage, invoker };
|
|
54
|
+
writer.write_states(section(submitted, "states"), &existing).await?;
|
|
55
|
+
writer.write_collections(section(submitted, "collections"), &existing).await?;
|
|
56
|
+
writer.write_overview(section(submitted, "overview"), &existing).await?;
|
|
57
|
+
|
|
58
|
+
presentation::load(client, wasm_path, lineage).await.map_err(|e| SaveRefusal::Internal(format!("{e:#}")))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A missing, null, or non-object section is the empty one rather than an error: `validate` has
|
|
62
|
+
// already run every real check against the same value.
|
|
63
|
+
fn section<'a>(config: &'a Value, key: &str) -> &'a Value {
|
|
64
|
+
const EMPTY: &Value = &Value::Null;
|
|
65
|
+
config.get(key).filter(|v| v.is_object()).unwrap_or(EMPTY)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn entries(value: &Value) -> Vec<(&String, &Value)> {
|
|
69
|
+
value.as_object().map(|map| map.iter().collect()).unwrap_or_default()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A null, a missing key, or anything that isn't a list is the empty list.
|
|
73
|
+
fn list(value: Option<&Value>) -> &[Value] {
|
|
74
|
+
value.and_then(|v| v.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every present field is re-dispatched on every save, even to an unchanged value, so the two
|
|
78
|
+
// engines produce the same history for the same request. A field once set and later omitted is
|
|
79
|
+
// never cleared: the chapter declares no `Clear*` command.
|
|
80
|
+
struct Writer<'a> {
|
|
81
|
+
client: &'a Mutex<Client>,
|
|
82
|
+
wasm_path: &'a Path,
|
|
83
|
+
lineage: &'a LineageConfig,
|
|
84
|
+
invoker: &'a dyn LambdaInvoker,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
impl Writer<'_> {
|
|
88
|
+
// A creating command — facts only, no identity to route to.
|
|
89
|
+
async fn declare(&mut self, verb: &str, facts: Value) -> Result<(), SaveRefusal> {
|
|
90
|
+
let outcome = dispatch::handle_facts(self.client, self.wasm_path, verb, facts, None, self.lineage, self.invoker)
|
|
91
|
+
.await
|
|
92
|
+
.map_err(|e| SaveRefusal::Internal(format!("{e:#}")))?;
|
|
93
|
+
self.accepted(outcome)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A command acting on an existing row, routed by that row's own identity: "Agg:state" for
|
|
97
|
+
// StateStyle, the aggregate name for Collection, "overview" for the singleton.
|
|
98
|
+
async fn route(&mut self, verb: &str, id: &str, facts: Value) -> Result<(), SaveRefusal> {
|
|
99
|
+
let outcome =
|
|
100
|
+
dispatch::handle_routed(self.client, self.wasm_path, verb, json!(id), facts, None, self.lineage, self.invoker)
|
|
101
|
+
.await
|
|
102
|
+
.map_err(|e| SaveRefusal::Internal(format!("{e:#}")))?;
|
|
103
|
+
self.accepted(outcome)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A refusal stops the whole save where it happened: several independent dispatches, not one
|
|
107
|
+
// atomic write. `validate` above is what makes this rare rather than routine.
|
|
108
|
+
fn accepted(&self, outcome: dispatch::Outcome) -> Result<(), SaveRefusal> {
|
|
109
|
+
if outcome.accepted {
|
|
110
|
+
Ok(())
|
|
111
|
+
} else {
|
|
112
|
+
Err(SaveRefusal::Domain(outcome.result))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async fn write_states(&mut self, states: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
|
|
117
|
+
let known: BTreeSet<&str> = existing.state_ids();
|
|
118
|
+
for (agg, per_state) in entries(states) {
|
|
119
|
+
for (state, entry) in entries(per_state) {
|
|
120
|
+
let id = format!("{agg}:{state}");
|
|
121
|
+
if !known.contains(id.as_str()) {
|
|
122
|
+
self.declare(
|
|
123
|
+
"ConsoleSettings::StateStyle.Declare",
|
|
124
|
+
json!({"agg": {"value": agg}, "state": {"value": state}}),
|
|
125
|
+
)
|
|
126
|
+
.await?;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if let Some(tone) = present(entry.get("tone")) {
|
|
130
|
+
self.route("ConsoleSettings::StateStyle.SetTone", &id, json!({"tone": {"value": tone}})).await?;
|
|
131
|
+
}
|
|
132
|
+
// Key presence, not truthiness: an explicit `false` is a real answer to store.
|
|
133
|
+
if let Some(attention) = entry.get("attention") {
|
|
134
|
+
let flag = if attention.as_bool() == Some(true) { "true" } else { "false" };
|
|
135
|
+
self.route("ConsoleSettings::StateStyle.SetAttention", &id, json!({"attention": {"value": flag}}))
|
|
136
|
+
.await?;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let extra = extra_fields(entry, KNOWN_STATE_KEYS);
|
|
140
|
+
if !extra.is_empty() {
|
|
141
|
+
self.route(
|
|
142
|
+
"ConsoleSettings::StateStyle.SetExtra",
|
|
143
|
+
&id,
|
|
144
|
+
json!({"extra_json": {"value": Value::Object(extra).to_string()}}),
|
|
145
|
+
)
|
|
146
|
+
.await?;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
Ok(())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async fn write_collections(&mut self, collections: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
|
|
154
|
+
let known: BTreeSet<&str> = existing.collection_ids();
|
|
155
|
+
for (agg, entry) in entries(collections) {
|
|
156
|
+
if !known.contains(agg.as_str()) {
|
|
157
|
+
self.declare("ConsoleSettings::Collection.Declare", json!({"agg": {"value": agg}})).await?;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (field, command) in [
|
|
161
|
+
("label", "SetLabel"),
|
|
162
|
+
("key", "SetKey"),
|
|
163
|
+
("nav_order", "SetNavOrder"),
|
|
164
|
+
("primary_field", "SetPrimaryField"),
|
|
165
|
+
("list_query", "SetListQuery"),
|
|
166
|
+
] {
|
|
167
|
+
if let Some(value) = present(entry.get(field)) {
|
|
168
|
+
let verb = format!("ConsoleSettings::Collection.{command}");
|
|
169
|
+
self.route(&verb, agg, json!({ field: {"value": value} })).await?;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if let Some(identity) = entry.get("identity").filter(|v| v.is_object()) {
|
|
174
|
+
self.write_identity(agg, identity).await?;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Always dispatched, even for an absent list: an empty `Replace*` is the meaningful
|
|
178
|
+
// "none configured", and is how a column removed from the config goes away.
|
|
179
|
+
self.route(
|
|
180
|
+
"ConsoleSettings::Collection.ReplaceColumns",
|
|
181
|
+
agg,
|
|
182
|
+
json!({ "columns": columns_for_dispatch(entry.get("columns")) }),
|
|
183
|
+
)
|
|
184
|
+
.await?;
|
|
185
|
+
self.route(
|
|
186
|
+
"ConsoleSettings::Collection.ReplaceDetailFields",
|
|
187
|
+
agg,
|
|
188
|
+
json!({ "detail_fields": detail_fields_for_dispatch(entry.get("detail_fields")) }),
|
|
189
|
+
)
|
|
190
|
+
.await?;
|
|
191
|
+
self.route(
|
|
192
|
+
"ConsoleSettings::Collection.ReplaceDetailFieldColumns",
|
|
193
|
+
agg,
|
|
194
|
+
json!({ "detail_field_columns": detail_field_columns_for_dispatch(entry.get("detail_fields")) }),
|
|
195
|
+
)
|
|
196
|
+
.await?;
|
|
197
|
+
self.route(
|
|
198
|
+
"ConsoleSettings::Collection.ReplacePreconditions",
|
|
199
|
+
agg,
|
|
200
|
+
json!({ "preconditions": preconditions_for_dispatch(entry.get("preconditions")) }),
|
|
201
|
+
)
|
|
202
|
+
.await?;
|
|
203
|
+
self.route(
|
|
204
|
+
"ConsoleSettings::Collection.ReplaceFieldFormats",
|
|
205
|
+
agg,
|
|
206
|
+
json!({ "field_formats": field_formats_for_dispatch(entry.get("field_formats")) }),
|
|
207
|
+
)
|
|
208
|
+
.await?;
|
|
209
|
+
|
|
210
|
+
let extra = extra_fields(entry, KNOWN_COLLECTION_KEYS);
|
|
211
|
+
if !extra.is_empty() {
|
|
212
|
+
self.route(
|
|
213
|
+
"ConsoleSettings::Collection.SetExtra",
|
|
214
|
+
agg,
|
|
215
|
+
json!({"extra_json": {"value": Value::Object(extra).to_string()}}),
|
|
216
|
+
)
|
|
217
|
+
.await?;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
Ok(())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async fn write_identity(&mut self, agg: &str, identity: &Value) -> Result<(), SaveRefusal> {
|
|
224
|
+
let mut facts = Map::new();
|
|
225
|
+
facts.insert("identity_field".to_string(), json!({"value": identity.get("field")}));
|
|
226
|
+
facts.insert("identity_strategy".to_string(), json!({"value": identity.get("strategy")}));
|
|
227
|
+
if let Some(source) = present(identity.get("source")) {
|
|
228
|
+
facts.insert("identity_source".to_string(), json!({ "value": source }));
|
|
229
|
+
}
|
|
230
|
+
if let Some(prefix) = present(identity.get("prefix")) {
|
|
231
|
+
facts.insert("identity_prefix".to_string(), json!({ "value": prefix }));
|
|
232
|
+
}
|
|
233
|
+
let extra = extra_fields(identity, KNOWN_IDENTITY_KEYS);
|
|
234
|
+
if !extra.is_empty() {
|
|
235
|
+
facts.insert("identity_extra_json".to_string(), json!({"value": Value::Object(extra).to_string()}));
|
|
236
|
+
}
|
|
237
|
+
self.route("ConsoleSettings::Collection.SetIdentity", agg, Value::Object(facts)).await
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The one row: `Declare`d once, then `ReplaceStats` whole on every save, even an empty list.
|
|
241
|
+
async fn write_overview(&mut self, overview: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
|
|
242
|
+
if existing.overview.is_empty() {
|
|
243
|
+
self.declare("ConsoleSettings::Overview.Declare", json!({"key": {"value": "overview"}})).await?;
|
|
244
|
+
}
|
|
245
|
+
self.route(
|
|
246
|
+
"ConsoleSettings::Overview.ReplaceStats",
|
|
247
|
+
"overview",
|
|
248
|
+
json!({ "stats": stats_for_dispatch(overview.get("stats")) }),
|
|
249
|
+
)
|
|
250
|
+
.await
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Whatever a real entry carries beyond its KNOWN_*_KEYS is round-tripped through `extra_json`,
|
|
255
|
+
// read back by `presentation.rs`'s `merge_extra`. Order matches Ruby's own constants exactly.
|
|
256
|
+
const KNOWN_STATE_KEYS: &[&str] = &["tone", "attention"];
|
|
257
|
+
const KNOWN_COLLECTION_KEYS: &[&str] = &[
|
|
258
|
+
"label",
|
|
259
|
+
"key",
|
|
260
|
+
"nav_order",
|
|
261
|
+
"primary_field",
|
|
262
|
+
"list_query",
|
|
263
|
+
"identity",
|
|
264
|
+
"columns",
|
|
265
|
+
"detail_fields",
|
|
266
|
+
"preconditions",
|
|
267
|
+
"field_formats",
|
|
268
|
+
"after_create",
|
|
269
|
+
];
|
|
270
|
+
const KNOWN_IDENTITY_KEYS: &[&str] = &["field", "strategy", "source", "prefix"];
|
|
271
|
+
const KNOWN_COLUMN_KEYS: &[&str] = &["field", "sortable", "sort_default"];
|
|
272
|
+
const KNOWN_DETAIL_FIELD_KEYS: &[&str] = &["field", "display", "state_filter", "columns"];
|
|
273
|
+
const KNOWN_PRECONDITION_KEYS: &[&str] = &["field", "state"];
|
|
274
|
+
|
|
275
|
+
fn extra_fields(entry: &Value, known: &[&str]) -> Map<String, Value> {
|
|
276
|
+
let mut extra = Map::new();
|
|
277
|
+
if let Some(object) = entry.as_object() {
|
|
278
|
+
for (key, value) in object {
|
|
279
|
+
if !known.contains(&key.as_str()) {
|
|
280
|
+
extra.insert(key.clone(), value.clone());
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
extra
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A missing key and an explicit null are both "not given". `false` is never filtered here;
|
|
288
|
+
// `attention`, the one field that can legitimately be `false`, is read by key presence instead.
|
|
289
|
+
fn present(value: Option<&Value>) -> Option<&Value> {
|
|
290
|
+
value.filter(|v| !v.is_null())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fn columns_for_dispatch(columns: Option<&Value>) -> Vec<Value> {
|
|
294
|
+
list(columns)
|
|
295
|
+
.iter()
|
|
296
|
+
.map(|column| {
|
|
297
|
+
// A column is either a bare field name or a descriptor hash — both shapes
|
|
298
|
+
// `validate_collection!` reads.
|
|
299
|
+
let Some(object) = column.as_object() else { return json!({ "field": column }) };
|
|
300
|
+
let mut row = Map::new();
|
|
301
|
+
row.insert("field".to_string(), object.get("field").cloned().unwrap_or(Value::Null));
|
|
302
|
+
// "true"/"false" as strings: `Column#sortable` is a String attribute, not a boolean.
|
|
303
|
+
if let Some(sortable) = object.get("sortable") {
|
|
304
|
+
row.insert("sortable".to_string(), json!(ruby_to_s(sortable)));
|
|
305
|
+
}
|
|
306
|
+
if let Some(sort_default) = present(object.get("sort_default")) {
|
|
307
|
+
row.insert("sort_default".to_string(), sort_default.clone());
|
|
308
|
+
}
|
|
309
|
+
let extra = extra_fields(column, KNOWN_COLUMN_KEYS);
|
|
310
|
+
if !extra.is_empty() {
|
|
311
|
+
row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
|
|
312
|
+
}
|
|
313
|
+
Value::Object(row)
|
|
314
|
+
})
|
|
315
|
+
.collect()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fn detail_fields_for_dispatch(detail_fields: Option<&Value>) -> Vec<Value> {
|
|
319
|
+
list(detail_fields)
|
|
320
|
+
.iter()
|
|
321
|
+
.map(|field| {
|
|
322
|
+
let Some(object) = field.as_object() else { return json!({ "field": field }) };
|
|
323
|
+
let mut row = Map::new();
|
|
324
|
+
row.insert("field".to_string(), object.get("field").cloned().unwrap_or(Value::Null));
|
|
325
|
+
if let Some(display) = present(object.get("display")) {
|
|
326
|
+
row.insert("display".to_string(), display.clone());
|
|
327
|
+
}
|
|
328
|
+
if let Some(state_filter) = present(object.get("state_filter")) {
|
|
329
|
+
row.insert("state_filter".to_string(), state_filter.clone());
|
|
330
|
+
}
|
|
331
|
+
let extra = extra_fields(field, KNOWN_DETAIL_FIELD_KEYS);
|
|
332
|
+
if !extra.is_empty() {
|
|
333
|
+
row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
|
|
334
|
+
}
|
|
335
|
+
Value::Object(row)
|
|
336
|
+
})
|
|
337
|
+
.collect()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Flattened to one row per (field, column) pair; `presentation.rs` groups it back by field.
|
|
341
|
+
fn detail_field_columns_for_dispatch(detail_fields: Option<&Value>) -> Vec<Value> {
|
|
342
|
+
let mut rows = Vec::new();
|
|
343
|
+
for field in list(detail_fields) {
|
|
344
|
+
let Some(object) = field.as_object() else { continue };
|
|
345
|
+
let Some(columns) = object.get("columns") else { continue };
|
|
346
|
+
let name = object.get("field").cloned().unwrap_or(Value::Null);
|
|
347
|
+
for column in list(Some(columns)) {
|
|
348
|
+
rows.push(json!({ "field": name, "column": column }));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
rows
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
fn preconditions_for_dispatch(preconditions: Option<&Value>) -> Vec<Value> {
|
|
355
|
+
list(preconditions)
|
|
356
|
+
.iter()
|
|
357
|
+
.map(|rule| {
|
|
358
|
+
let mut row = Map::new();
|
|
359
|
+
row.insert("field".to_string(), rule.get("field").cloned().unwrap_or(Value::Null));
|
|
360
|
+
row.insert("state".to_string(), rule.get("state").cloned().unwrap_or(Value::Null));
|
|
361
|
+
let extra = extra_fields(rule, KNOWN_PRECONDITION_KEYS);
|
|
362
|
+
if !extra.is_empty() {
|
|
363
|
+
row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
|
|
364
|
+
}
|
|
365
|
+
Value::Object(row)
|
|
366
|
+
})
|
|
367
|
+
.collect()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A config map (`{field => format}`) becomes a list of `{field, format}` rows; `presentation.rs`
|
|
371
|
+
// undoes the reshape on the way out.
|
|
372
|
+
fn field_formats_for_dispatch(field_formats: Option<&Value>) -> Vec<Value> {
|
|
373
|
+
field_formats
|
|
374
|
+
.and_then(|v| v.as_object())
|
|
375
|
+
.map(|map| map.iter().map(|(field, format)| json!({"field": field, "format": format})).collect())
|
|
376
|
+
.unwrap_or_default()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn stats_for_dispatch(stats: Option<&Value>) -> Vec<Value> {
|
|
380
|
+
list(stats)
|
|
381
|
+
.iter()
|
|
382
|
+
.map(|stat| {
|
|
383
|
+
let mut row = Map::new();
|
|
384
|
+
row.insert("label".to_string(), stat.get("label").cloned().unwrap_or(Value::Null));
|
|
385
|
+
row.insert("collection".to_string(), stat.get("collection").cloned().unwrap_or(Value::Null));
|
|
386
|
+
if let Some(fold) = present(stat.get("as")) {
|
|
387
|
+
row.insert("as".to_string(), fold.clone());
|
|
388
|
+
}
|
|
389
|
+
if let Some(field) = present(stat.get("field")) {
|
|
390
|
+
row.insert("field".to_string(), field.clone());
|
|
391
|
+
}
|
|
392
|
+
// Verified-then-opaque storage: hecks has no attribute type for an arbitrary nested
|
|
393
|
+
// map, and `validate_where` below has already walked the real structure.
|
|
394
|
+
if let Some(clause) = present(stat.get("where")) {
|
|
395
|
+
row.insert("where_json".to_string(), json!(clause.to_string()));
|
|
396
|
+
}
|
|
397
|
+
Value::Object(row)
|
|
398
|
+
})
|
|
399
|
+
.collect()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Ruby's `#to_s`: a JSON string keeps its own characters (never `"\"true\""`).
|
|
403
|
+
fn ruby_to_s(value: &Value) -> String {
|
|
404
|
+
match value {
|
|
405
|
+
Value::String(text) => text.clone(),
|
|
406
|
+
Value::Null => String::new(),
|
|
407
|
+
other => other.to_string(),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Every message below matches `PresentationConfig`'s own wording exactly, pinned by the tests
|
|
412
|
+
// at the bottom of this file, so a console that switched runtimes explains a mistake the same way.
|
|
413
|
+
type Refusal = Result<(), String>;
|
|
414
|
+
|
|
415
|
+
// Checks in the order Ruby does: states, the both-directions completeness check, collections,
|
|
416
|
+
// the overview, then the one deliberate scope boundary.
|
|
417
|
+
pub(crate) fn validate(config: &Value, domain_ir: &Value) -> Refusal {
|
|
418
|
+
let aggregates = ui_schema::aggregates(domain_ir);
|
|
419
|
+
let states = section(config, "states");
|
|
420
|
+
let collections = section(config, "collections");
|
|
421
|
+
|
|
422
|
+
for (agg_name, per_state) in entries(states) {
|
|
423
|
+
validate_states(agg_name, per_state, &aggregates)?;
|
|
424
|
+
}
|
|
425
|
+
missing_state_entries(states, &aggregates)?;
|
|
426
|
+
for (agg_name, entry) in entries(collections) {
|
|
427
|
+
validate_collection(agg_name, entry, &aggregates, domain_ir)?;
|
|
428
|
+
}
|
|
429
|
+
validate_overview(section(config, "overview"), &aggregates, config)?;
|
|
430
|
+
refuse_unsupported_yet(collections)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// `after_create` is validated in full above but has nowhere to persist yet; refusing here means
|
|
434
|
+
// a caller who tries loses nothing silently. A deliberate scope boundary, not a bug.
|
|
435
|
+
fn refuse_unsupported_yet(collections: &Value) -> Refusal {
|
|
436
|
+
for (agg_name, entry) in entries(collections) {
|
|
437
|
+
if present(entry.get("after_create")).is_some() {
|
|
438
|
+
return Err(format!(
|
|
439
|
+
"{agg_name}'s after_create isn't stored by ConsoleSettings yet \
|
|
440
|
+
(see console_settings.bluebook's own header) — remove it before saving"
|
|
441
|
+
));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
Ok(())
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fn find_aggregate<'a>(aggregates: &[&'a Value], name: &str) -> Option<&'a Value> {
|
|
448
|
+
aggregates.iter().copied().find(|a| ui_schema::agg_name(a) == name)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
fn require_aggregate<'a>(aggregates: &[&'a Value], name: &str) -> Result<&'a Value, String> {
|
|
452
|
+
find_aggregate(aggregates, name).ok_or_else(|| format!("{name} is not a real aggregate this domain declares"))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
fn validate_states(agg_name: &str, per_state: &Value, aggregates: &[&Value]) -> Refusal {
|
|
456
|
+
let aggregate = require_aggregate(aggregates, agg_name)?;
|
|
457
|
+
let known = ui_schema::lifecycle_states(aggregate);
|
|
458
|
+
for (state_name, entry) in entries(per_state) {
|
|
459
|
+
validate_state_entry(agg_name, state_name, entry, &known)?;
|
|
460
|
+
}
|
|
461
|
+
Ok(())
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Both directions: checking only that `states` never names an unreal state would still let a
|
|
465
|
+
// real state go quietly unstyled forever.
|
|
466
|
+
fn missing_state_entries(states: &Value, aggregates: &[&Value]) -> Refusal {
|
|
467
|
+
let mut missing = Vec::new();
|
|
468
|
+
for aggregate in aggregates {
|
|
469
|
+
let name = ui_schema::agg_name(aggregate);
|
|
470
|
+
let configured = states.get(name);
|
|
471
|
+
for state in ui_schema::lifecycle_states(aggregate) {
|
|
472
|
+
let styled = configured.and_then(|c| c.get(&state)).is_some();
|
|
473
|
+
if !styled {
|
|
474
|
+
missing.push(format!("{name}.{state}"));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if missing.is_empty() {
|
|
479
|
+
return Ok(());
|
|
480
|
+
}
|
|
481
|
+
Err(format!("no presentation entry for {} — every real state needs one, even a bare one", missing.join(", ")))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fn validate_state_entry(agg_name: &str, state_name: &str, entry: &Value, known: &[String]) -> Refusal {
|
|
485
|
+
if !known.iter().any(|s| s == state_name) {
|
|
486
|
+
return Err(format!(
|
|
487
|
+
"{agg_name} has no state {} — it declares {}",
|
|
488
|
+
inspect(&json!(state_name)),
|
|
489
|
+
known.join(", ")
|
|
490
|
+
));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if let Some(tone) = present(entry.get("tone")) {
|
|
494
|
+
if !VALID_TONES.contains(&ruby_to_s(tone).as_str()) {
|
|
495
|
+
return Err(format!(
|
|
496
|
+
"{agg_name}.{state_name}'s tone {} isn't one of {}",
|
|
497
|
+
inspect(tone),
|
|
498
|
+
VALID_TONES.join(", ")
|
|
499
|
+
));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
match entry.get("attention") {
|
|
504
|
+
None | Some(Value::Null) | Some(Value::Bool(_)) => Ok(()),
|
|
505
|
+
Some(other) => {
|
|
506
|
+
Err(format!("{agg_name}.{state_name}'s attention must be true or false, not {}", inspect(other)))
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
fn validate_collection(agg_name: &str, entry: &Value, aggregates: &[&Value], domain_ir: &Value) -> Refusal {
|
|
512
|
+
let aggregate = require_aggregate(aggregates, agg_name)?;
|
|
513
|
+
|
|
514
|
+
validate_columns(agg_name, aggregate, entry.get("columns"))?;
|
|
515
|
+
|
|
516
|
+
// `nav_order` is the one place a non-string scalar is meaningful,
|
|
517
|
+
// so it is the one place the check is about the JSON type rather
|
|
518
|
+
// than a vocabulary.
|
|
519
|
+
if let Some(nav_order) = entry.get("nav_order") {
|
|
520
|
+
if !nav_order.is_number() {
|
|
521
|
+
return Err(format!("{agg_name}'s nav_order must be a number, not {}", inspect(nav_order)));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for rule in list(entry.get("preconditions")) {
|
|
526
|
+
validate_precondition(agg_name, aggregate, rule, aggregates)?;
|
|
527
|
+
}
|
|
528
|
+
validate_field_formats(agg_name, aggregate, entry.get("field_formats"))?;
|
|
529
|
+
if let Some(identity) = present(entry.get("identity")) {
|
|
530
|
+
validate_identity(agg_name, aggregate, identity)?;
|
|
531
|
+
}
|
|
532
|
+
validate_detail_fields(agg_name, aggregate, entry.get("detail_fields"))?;
|
|
533
|
+
if let Some(list_query) = present(entry.get("list_query")) {
|
|
534
|
+
validate_list_query(agg_name, aggregate, list_query)?;
|
|
535
|
+
}
|
|
536
|
+
if let Some(after_create) = present(entry.get("after_create")) {
|
|
537
|
+
validate_after_create(agg_name, after_create, aggregates, domain_ir)?;
|
|
538
|
+
}
|
|
539
|
+
Ok(())
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Looser than state validation: a column names an attribute (or `"__state__"`), so the check is
|
|
543
|
+
// "does this aggregate declare it", not a closed vocabulary.
|
|
544
|
+
fn validate_columns(agg_name: &str, aggregate: &Value, columns: Option<&Value>) -> Refusal {
|
|
545
|
+
for column in list(columns) {
|
|
546
|
+
let key = column.get("field").filter(|_| column.is_object()).unwrap_or(column);
|
|
547
|
+
let name = ruby_to_s(key);
|
|
548
|
+
if name != "__state__" && ui_schema::find_attribute(aggregate, &name).is_none() {
|
|
549
|
+
return Err(format!(
|
|
550
|
+
"{agg_name} has no attribute {} to put in its columns — it declares {}",
|
|
551
|
+
inspect(key),
|
|
552
|
+
attribute_names(aggregate)
|
|
553
|
+
));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let Some(object) = column.as_object() else { continue };
|
|
557
|
+
if let Some(sortable) = object.get("sortable") {
|
|
558
|
+
if !sortable.is_boolean() {
|
|
559
|
+
return Err(format!(
|
|
560
|
+
"{agg_name}'s column {} has sortable {}, not true or false",
|
|
561
|
+
inspect(key),
|
|
562
|
+
inspect(sortable)
|
|
563
|
+
));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if let Some(sort_default) = present(object.get("sort_default")) {
|
|
567
|
+
if !VALID_SORTS.contains(&ruby_to_s(sort_default).as_str()) {
|
|
568
|
+
return Err(format!(
|
|
569
|
+
"{agg_name}'s column {} has sort_default {}, not asc or desc",
|
|
570
|
+
inspect(key),
|
|
571
|
+
inspect(sort_default)
|
|
572
|
+
));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
Ok(())
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
fn attribute_names(aggregate: &Value) -> String {
|
|
580
|
+
ui_schema::array(aggregate, "attributes").iter().map(ui_schema::attr_name).collect::<Vec<_>>().join(", ")
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// A non-reference field is caught here first, since otherwise the state check below would
|
|
584
|
+
// simply never fire.
|
|
585
|
+
fn validate_precondition(agg_name: &str, aggregate: &Value, rule: &Value, aggregates: &[&Value]) -> Refusal {
|
|
586
|
+
let Some(field) = present(rule.get("field")) else {
|
|
587
|
+
return Err(format!("{agg_name} has a precondition with no field named"));
|
|
588
|
+
};
|
|
589
|
+
let name = ruby_to_s(field);
|
|
590
|
+
let attribute = ui_schema::find_attribute(aggregate, &name);
|
|
591
|
+
let is_reference = attribute.map(ui_schema::is_reference).unwrap_or(false);
|
|
592
|
+
if !is_reference {
|
|
593
|
+
return Err(format!("{agg_name}.{name} isn't a reference — a precondition only makes sense against one"));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let Some(state) = present(rule.get("state")) else { return Ok(()) };
|
|
597
|
+
// A cross-domain or otherwise unresolvable target — nothing more to
|
|
598
|
+
// check here, exactly as Ruby's own `return unless target` says.
|
|
599
|
+
let Some(target_name) = ui_schema::reference_target(ui_schema::type_of(attribute.expect("checked above"))) else {
|
|
600
|
+
return Ok(());
|
|
601
|
+
};
|
|
602
|
+
let Some(target) = find_aggregate(aggregates, &target_name) else { return Ok(()) };
|
|
603
|
+
|
|
604
|
+
let known = ui_schema::lifecycle_states(target);
|
|
605
|
+
if known.iter().any(|s| *s == ruby_to_s(state)) {
|
|
606
|
+
return Ok(());
|
|
607
|
+
}
|
|
608
|
+
Err(format!(
|
|
609
|
+
"{agg_name}.{name} points at {target_name}, which has no state {} — it declares {}",
|
|
610
|
+
inspect(state),
|
|
611
|
+
known.join(", ")
|
|
612
|
+
))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Keyed by attribute name, not path: a nested compound field is found the same way, since
|
|
616
|
+
// `ui_schema` threads the same map down through every level of recursion.
|
|
617
|
+
fn validate_field_formats(agg_name: &str, aggregate: &Value, formats: Option<&Value>) -> Refusal {
|
|
618
|
+
let Some(map) = formats.and_then(|v| v.as_object()) else { return Ok(()) };
|
|
619
|
+
for (field_name, format) in map {
|
|
620
|
+
if ui_schema::find_attribute(aggregate, field_name).is_none() && !nested_attribute(aggregate, field_name) {
|
|
621
|
+
return Err(format!("{agg_name} has no attribute {} to format", inspect(&json!(field_name))));
|
|
622
|
+
}
|
|
623
|
+
if !VALID_FORMATS.contains(&ruby_to_s(format).as_str()) {
|
|
624
|
+
return Err(format!(
|
|
625
|
+
"{agg_name}.{field_name}'s format {} isn't one of {}",
|
|
626
|
+
inspect(format),
|
|
627
|
+
VALID_FORMATS.join(", ")
|
|
628
|
+
));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
Ok(())
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
fn nested_attribute(aggregate: &Value, field_name: &str) -> bool {
|
|
635
|
+
ui_schema::array(aggregate, "attributes").iter().any(|attribute| {
|
|
636
|
+
if ui_schema::is_reference(attribute) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
let Some(value_object) = ui_schema::find_value_object(aggregate, ui_schema::type_of(attribute)) else {
|
|
640
|
+
return false;
|
|
641
|
+
};
|
|
642
|
+
ui_schema::array(value_object, "attributes").iter().any(|sub| ui_schema::attr_name(sub) == field_name)
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Never a reference: there is nothing to derive from another aggregate's identity.
|
|
647
|
+
fn validate_identity(agg_name: &str, aggregate: &Value, rule: &Value) -> Refusal {
|
|
648
|
+
let Some(field) = present(rule.get("field")) else {
|
|
649
|
+
return Err(format!("{agg_name} has an identity rule with no field named"));
|
|
650
|
+
};
|
|
651
|
+
let name = ruby_to_s(field);
|
|
652
|
+
let Some(attribute) = ui_schema::find_attribute(aggregate, &name) else {
|
|
653
|
+
return Err(format!("{agg_name} has no attribute {} to auto-derive", inspect(field)));
|
|
654
|
+
};
|
|
655
|
+
if ui_schema::is_reference(attribute) {
|
|
656
|
+
return Err(format!("{agg_name}.{name} is a reference — identity can only derive a plain field"));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
match rule.get("strategy").map(ruby_to_s).unwrap_or_default().as_str() {
|
|
660
|
+
"slug" => {
|
|
661
|
+
let Some(source) = present(rule.get("source")) else {
|
|
662
|
+
return Err(format!("{agg_name}'s identity slug strategy needs a source field"));
|
|
663
|
+
};
|
|
664
|
+
if ui_schema::find_attribute(aggregate, &ruby_to_s(source)).is_none() {
|
|
665
|
+
return Err(format!("{agg_name} has no attribute {} to slug from", inspect(source)));
|
|
666
|
+
}
|
|
667
|
+
Ok(())
|
|
668
|
+
}
|
|
669
|
+
"sequence" => {
|
|
670
|
+
if present(rule.get("prefix")).is_none() {
|
|
671
|
+
return Err(format!("{agg_name}'s identity sequence strategy needs a prefix"));
|
|
672
|
+
}
|
|
673
|
+
Ok(())
|
|
674
|
+
}
|
|
675
|
+
// No further config to check — the domain's own
|
|
676
|
+
// identity_assignment adapter decides everything, and a domain
|
|
677
|
+
// with none wired refuses loudly at dispatch time, not here.
|
|
678
|
+
"port" => Ok(()),
|
|
679
|
+
_ => Err(format!(
|
|
680
|
+
"{agg_name}'s identity strategy {} isn't 'slug', 'sequence', or 'port'",
|
|
681
|
+
inspect(rule.get("strategy").unwrap_or(&Value::Null))
|
|
682
|
+
)),
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
fn validate_detail_fields(agg_name: &str, aggregate: &Value, entries_value: Option<&Value>) -> Refusal {
|
|
687
|
+
for field in list(entries_value) {
|
|
688
|
+
let key = field.get("field").filter(|_| field.is_object()).unwrap_or(field);
|
|
689
|
+
let name = ruby_to_s(key);
|
|
690
|
+
let Some(attribute) = ui_schema::find_attribute(aggregate, &name) else {
|
|
691
|
+
return Err(format!(
|
|
692
|
+
"{agg_name} has no attribute {} to put in its detail fields — it declares {}",
|
|
693
|
+
inspect(key),
|
|
694
|
+
attribute_names(aggregate)
|
|
695
|
+
));
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
let Some(object) = field.as_object() else { continue };
|
|
699
|
+
|
|
700
|
+
if let Some(display) = present(object.get("display")) {
|
|
701
|
+
if !VALID_DISPLAYS.contains(&ruby_to_s(display).as_str()) {
|
|
702
|
+
return Err(format!(
|
|
703
|
+
"{agg_name}'s detail field {} has display {}, not one of {}",
|
|
704
|
+
inspect(key),
|
|
705
|
+
inspect(display),
|
|
706
|
+
VALID_DISPLAYS.join(", ")
|
|
707
|
+
));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if let Some(columns) = present(object.get("columns")) {
|
|
711
|
+
validate_row_columns(agg_name, aggregate, &name, attribute, columns)?;
|
|
712
|
+
}
|
|
713
|
+
if let Some(state_filter) = present(object.get("state_filter")) {
|
|
714
|
+
validate_row_state_filter(agg_name, aggregate, &name, attribute, state_filter)?;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
Ok(())
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Only meaningful for an entity-backed rows field: a value object has no lifecycle at all.
|
|
721
|
+
fn validate_row_state_filter(
|
|
722
|
+
agg_name: &str,
|
|
723
|
+
aggregate: &Value,
|
|
724
|
+
key: &str,
|
|
725
|
+
attribute: &Value,
|
|
726
|
+
state_filter: &Value,
|
|
727
|
+
) -> Refusal {
|
|
728
|
+
let type_name = ui_schema::type_of(attribute).to_string();
|
|
729
|
+
let Some(entity) = ui_schema::find_entity(aggregate, &type_name) else {
|
|
730
|
+
return Err(format!(
|
|
731
|
+
"{agg_name}'s detail field {} has state_filter: but isn't an entity-backed rows field",
|
|
732
|
+
inspect(&json!(key))
|
|
733
|
+
));
|
|
734
|
+
};
|
|
735
|
+
let known = ui_schema::lifecycle_states(entity);
|
|
736
|
+
if known.iter().any(|s| *s == ruby_to_s(state_filter)) {
|
|
737
|
+
return Ok(());
|
|
738
|
+
}
|
|
739
|
+
Err(format!(
|
|
740
|
+
"{agg_name}'s detail field {} has state_filter {}, but {type_name} has no such state — it declares {}",
|
|
741
|
+
inspect(&json!(key)),
|
|
742
|
+
inspect(state_filter),
|
|
743
|
+
known.join(", ")
|
|
744
|
+
))
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// A `columns:` on a field that is neither a value object nor an entity is a config mistake
|
|
748
|
+
// this catches by refusing rather than silently doing nothing.
|
|
749
|
+
fn validate_row_columns(agg_name: &str, aggregate: &Value, key: &str, attribute: &Value, columns: &Value) -> Refusal {
|
|
750
|
+
let type_name = ui_schema::type_of(attribute).to_string();
|
|
751
|
+
let rows_source = ui_schema::find_value_object(aggregate, &type_name)
|
|
752
|
+
.or_else(|| ui_schema::find_entity(aggregate, &type_name))
|
|
753
|
+
.filter(|_| ui_schema::is_list(attribute));
|
|
754
|
+
let Some(rows_source) = rows_source else {
|
|
755
|
+
return Err(format!(
|
|
756
|
+
"{agg_name}'s detail field {} has columns: but isn't a rows-shaped field",
|
|
757
|
+
inspect(&json!(key))
|
|
758
|
+
));
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
let known: Vec<&str> = ui_schema::array(rows_source, "attributes").iter().map(ui_schema::attr_name).collect();
|
|
762
|
+
for column in list(Some(columns)) {
|
|
763
|
+
if !known.contains(&ruby_to_s(column).as_str()) {
|
|
764
|
+
return Err(format!(
|
|
765
|
+
"{agg_name}'s detail field {} has no row column {} — it has {}",
|
|
766
|
+
inspect(&json!(key)),
|
|
767
|
+
inspect(column),
|
|
768
|
+
known.join(", ")
|
|
769
|
+
));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
Ok(())
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// No-arg only: a query needing its own arguments has nowhere in a generic table view to get
|
|
776
|
+
// one from.
|
|
777
|
+
fn validate_list_query(agg_name: &str, aggregate: &Value, query_name: &Value) -> Refusal {
|
|
778
|
+
let name = ruby_to_s(query_name);
|
|
779
|
+
let Some(query) = find_query(aggregate, &name) else {
|
|
780
|
+
return Err(format!("{agg_name} has no query {} to use as its list_query", inspect(query_name)));
|
|
781
|
+
};
|
|
782
|
+
let arguments = ui_schema::array(query, "attributes");
|
|
783
|
+
if arguments.is_empty() {
|
|
784
|
+
return Ok(());
|
|
785
|
+
}
|
|
786
|
+
Err(format!(
|
|
787
|
+
"{agg_name}'s list_query {} takes its own arguments ({}) — a generic table view has nowhere to get them from",
|
|
788
|
+
inspect(query_name),
|
|
789
|
+
arguments.iter().map(ui_schema::attr_name).collect::<Vec<_>>().join(", ")
|
|
790
|
+
))
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
fn find_query<'a>(owner: &'a Value, name: &str) -> Option<&'a Value> {
|
|
794
|
+
ui_schema::array(owner, "queries").iter().find(|q| q.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Cannot check whether a `$field` exists on the source aggregate, since every one of its own
|
|
798
|
+
// attributes is fair game — a real gap, and the same one Ruby has.
|
|
799
|
+
fn validate_after_create(agg_name: &str, after_create: &Value, aggregates: &[&Value], domain_ir: &Value) -> Refusal {
|
|
800
|
+
let Some(dispatch_to) = present(after_create.get("dispatch")) else {
|
|
801
|
+
return Err(format!("{agg_name}'s after_create names no dispatch"));
|
|
802
|
+
};
|
|
803
|
+
let dotted = ruby_to_s(dispatch_to);
|
|
804
|
+
let (target_agg_name, target_command_name) = dotted.split_once('.').unwrap_or((dotted.as_str(), ""));
|
|
805
|
+
|
|
806
|
+
let Some(target_agg) = find_aggregate(aggregates, target_agg_name) else {
|
|
807
|
+
return Err(format!(
|
|
808
|
+
"{agg_name}'s after_create dispatches {}, but {} is not a real aggregate this domain declares",
|
|
809
|
+
inspect(dispatch_to),
|
|
810
|
+
inspect(&json!(target_agg_name))
|
|
811
|
+
));
|
|
812
|
+
};
|
|
813
|
+
let Some(target_command) = find_command(target_agg, target_command_name) else {
|
|
814
|
+
return Err(format!(
|
|
815
|
+
"{agg_name}'s after_create dispatches {}, but {target_agg_name} declares no command {}",
|
|
816
|
+
inspect(dispatch_to),
|
|
817
|
+
inspect(&json!(target_command_name))
|
|
818
|
+
));
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
let Some(args) = after_create.get("args").and_then(|v| v.as_object()) else { return Ok(()) };
|
|
822
|
+
for (key, spec) in args {
|
|
823
|
+
if ui_schema::find_attribute(target_command, key).is_none() {
|
|
824
|
+
return Err(format!(
|
|
825
|
+
"{agg_name}'s after_create names arg {}, but {dotted} declares no such attribute (it declares {})",
|
|
826
|
+
inspect(&json!(key)),
|
|
827
|
+
attribute_names(target_command)
|
|
828
|
+
));
|
|
829
|
+
}
|
|
830
|
+
validate_after_create_arg_spec(agg_name, key, spec, aggregates, domain_ir)?;
|
|
831
|
+
}
|
|
832
|
+
Ok(())
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
fn find_command<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
|
|
836
|
+
ui_schema::commands(aggregate).into_iter().find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
fn validate_after_create_arg_spec(
|
|
840
|
+
agg_name: &str,
|
|
841
|
+
key: &str,
|
|
842
|
+
spec: &Value,
|
|
843
|
+
aggregates: &[&Value],
|
|
844
|
+
_domain_ir: &Value,
|
|
845
|
+
) -> Refusal {
|
|
846
|
+
let Some(count_query) = spec.get("count_query").filter(|_| spec.is_object()).and_then(|v| present(Some(v))) else {
|
|
847
|
+
return Ok(());
|
|
848
|
+
};
|
|
849
|
+
let dotted = ruby_to_s(count_query);
|
|
850
|
+
let (source_agg_name, rest) = dotted.split_once('.').unwrap_or((dotted.as_str(), ""));
|
|
851
|
+
let Some(source_agg) = find_aggregate(aggregates, source_agg_name) else {
|
|
852
|
+
return Err(format!(
|
|
853
|
+
"{agg_name}'s after_create arg {} counts {}, but {} is not a real aggregate",
|
|
854
|
+
inspect(&json!(key)),
|
|
855
|
+
inspect(count_query),
|
|
856
|
+
inspect(&json!(source_agg_name))
|
|
857
|
+
));
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
if let Some((entity_name, query_name)) = rest.split_once('.') {
|
|
861
|
+
let Some(entity) = ui_schema::find_entity(source_agg, entity_name) else {
|
|
862
|
+
return Err(format!(
|
|
863
|
+
"{agg_name}'s after_create arg {} counts {}, but {source_agg_name} has no piece {}",
|
|
864
|
+
inspect(&json!(key)),
|
|
865
|
+
inspect(count_query),
|
|
866
|
+
inspect(&json!(entity_name))
|
|
867
|
+
));
|
|
868
|
+
};
|
|
869
|
+
if find_query(entity, query_name).is_none() {
|
|
870
|
+
return Err(format!(
|
|
871
|
+
"{agg_name}'s after_create arg {} counts {}, but {entity_name} declares no query {}",
|
|
872
|
+
inspect(&json!(key)),
|
|
873
|
+
inspect(count_query),
|
|
874
|
+
inspect(&json!(query_name))
|
|
875
|
+
));
|
|
876
|
+
}
|
|
877
|
+
} else if find_query(source_agg, rest).is_none() {
|
|
878
|
+
return Err(format!(
|
|
879
|
+
"{agg_name}'s after_create arg {} counts {}, but {source_agg_name} declares no query {}",
|
|
880
|
+
inspect(&json!(key)),
|
|
881
|
+
inspect(count_query),
|
|
882
|
+
inspect(&json!(rest))
|
|
883
|
+
));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if present(spec.get("count_scope_arg")).is_none() {
|
|
887
|
+
return Err(format!(
|
|
888
|
+
"{agg_name}'s after_create arg {} names count_query but no count_scope_arg",
|
|
889
|
+
inspect(&json!(key))
|
|
890
|
+
));
|
|
891
|
+
}
|
|
892
|
+
if present(spec.get("count_scope_value")).is_none() {
|
|
893
|
+
return Err(format!(
|
|
894
|
+
"{agg_name}'s after_create arg {} names count_query but no count_scope_value",
|
|
895
|
+
inspect(&json!(key))
|
|
896
|
+
));
|
|
897
|
+
}
|
|
898
|
+
Ok(())
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// `collection` names a collection key, the one `key:` config can override, since a stat sits
|
|
902
|
+
// above any one aggregate the same way a table does.
|
|
903
|
+
fn validate_overview(overview: &Value, aggregates: &[&Value], config: &Value) -> Refusal {
|
|
904
|
+
let by_key: Vec<(String, &Value)> =
|
|
905
|
+
aggregates.iter().map(|a| (ui_schema::collection_key(a, config), *a)).collect();
|
|
906
|
+
|
|
907
|
+
for stat in list(overview.get("stats")) {
|
|
908
|
+
validate_stat(stat, &by_key)?;
|
|
909
|
+
}
|
|
910
|
+
Ok(())
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
fn validate_stat(stat: &Value, by_key: &[(String, &Value)]) -> Refusal {
|
|
914
|
+
let Some(collection) = present(stat.get("collection")) else {
|
|
915
|
+
return Err("an overview stat has no collection named".to_string());
|
|
916
|
+
};
|
|
917
|
+
let key = ruby_to_s(collection);
|
|
918
|
+
let Some((_, aggregate)) = by_key.iter().find(|(k, _)| *k == key) else {
|
|
919
|
+
return Err(format!("overview stat names collection {}, which isn't a real collection key", inspect(collection)));
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
let label = stat.get("label").cloned().unwrap_or(Value::Null);
|
|
923
|
+
let fold = present(stat.get("as")).map(ruby_to_s).unwrap_or_else(|| "count".to_string());
|
|
924
|
+
if !VALID_FOLDS.contains(&fold.as_str()) {
|
|
925
|
+
return Err(format!(
|
|
926
|
+
"overview stat {}'s as: {} isn't one of {}",
|
|
927
|
+
inspect(&label),
|
|
928
|
+
inspect(&json!(fold)),
|
|
929
|
+
VALID_FOLDS.join(", ")
|
|
930
|
+
));
|
|
931
|
+
}
|
|
932
|
+
if fold == "money_sum" && present(stat.get("field")).is_none() {
|
|
933
|
+
return Err(format!("overview stat {} folds as money_sum but names no field", inspect(&label)));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
validate_where(stat.get("where").unwrap_or(&Value::Null), aggregate, by_key, &label)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Recurses through the same shape index.html's `matchesWhere` reads at evaluation time: `not:`
|
|
940
|
+
// wraps another whole clause, and `has_related`/`not_has_related` is checked below.
|
|
941
|
+
fn validate_where(clause: &Value, aggregate: &Value, by_key: &[(String, &Value)], label: &Value) -> Refusal {
|
|
942
|
+
let Some(object) = clause.as_object() else { return Ok(()) };
|
|
943
|
+
if let Some(negated) = object.get("not") {
|
|
944
|
+
return validate_where(negated, aggregate, by_key, label);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
for (key, condition) in object {
|
|
948
|
+
if key == "has_related" || key == "not_has_related" {
|
|
949
|
+
validate_has_related(condition, aggregate, by_key, label)?;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if key != "state" {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
let known = ui_schema::lifecycle_states(aggregate);
|
|
957
|
+
let named = match condition {
|
|
958
|
+
Value::Object(map) => map
|
|
959
|
+
.get("eq")
|
|
960
|
+
.or_else(|| map.get("ne"))
|
|
961
|
+
.or_else(|| map.get("in").and_then(|v| v.as_array()).and_then(|a| a.first()))
|
|
962
|
+
.cloned()
|
|
963
|
+
.unwrap_or(Value::Null),
|
|
964
|
+
other => other.clone(),
|
|
965
|
+
};
|
|
966
|
+
if named.is_null() || known.iter().any(|s| *s == ruby_to_s(&named)) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
return Err(format!(
|
|
970
|
+
"overview stat {}'s where: state {} isn't one of {}'s own states ({})",
|
|
971
|
+
inspect(label),
|
|
972
|
+
inspect(&named),
|
|
973
|
+
ui_schema::agg_name(aggregate),
|
|
974
|
+
known.join(", ")
|
|
975
|
+
));
|
|
976
|
+
}
|
|
977
|
+
Ok(())
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Spelled `field:`, not `on:` — YAML 1.1 reads a bare `on:` as the boolean `true`.
|
|
981
|
+
fn validate_has_related(spec: &Value, aggregate: &Value, by_key: &[(String, &Value)], label: &Value) -> Refusal {
|
|
982
|
+
let Some(target_key) = present(spec.get("collection")) else {
|
|
983
|
+
return Err(format!("overview stat {}'s has_related names no collection", inspect(label)));
|
|
984
|
+
};
|
|
985
|
+
let Some((_, target)) = by_key.iter().find(|(k, _)| *k == ruby_to_s(target_key)) else {
|
|
986
|
+
return Err(format!(
|
|
987
|
+
"overview stat {}'s has_related names collection {}, which isn't real",
|
|
988
|
+
inspect(label),
|
|
989
|
+
inspect(target_key)
|
|
990
|
+
));
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
let Some(field) = present(spec.get("field")) else {
|
|
994
|
+
return Err(format!("overview stat {}'s has_related names no field:", inspect(label)));
|
|
995
|
+
};
|
|
996
|
+
if ui_schema::find_attribute(target, &ruby_to_s(field)).is_none() {
|
|
997
|
+
return Err(format!(
|
|
998
|
+
"overview stat {}'s has_related.field {} isn't a real field on {}",
|
|
999
|
+
inspect(label),
|
|
1000
|
+
inspect(field),
|
|
1001
|
+
ui_schema::agg_name(target)
|
|
1002
|
+
));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let equals = present(spec.get("equals")).map(ruby_to_s).unwrap_or_else(|| "id".to_string());
|
|
1006
|
+
if equals == "id" || ui_schema::find_attribute(aggregate, &equals).is_some() {
|
|
1007
|
+
return Ok(());
|
|
1008
|
+
}
|
|
1009
|
+
Err(format!(
|
|
1010
|
+
"overview stat {}'s has_related.equals {} isn't a real field on {}",
|
|
1011
|
+
inspect(label),
|
|
1012
|
+
inspect(&json!(equals)),
|
|
1013
|
+
ui_schema::agg_name(aggregate)
|
|
1014
|
+
))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Ruby's `#inspect`: the refusal messages this module reproduces embed it directly, so any
|
|
1018
|
+
// other spelling would be a visible divergence.
|
|
1019
|
+
fn inspect(value: &Value) -> String {
|
|
1020
|
+
match value {
|
|
1021
|
+
Value::Null => "nil".to_string(),
|
|
1022
|
+
Value::Bool(flag) => flag.to_string(),
|
|
1023
|
+
// Ruby prints a whole Float with a trailing `.0`; serde_json
|
|
1024
|
+
// prints `4.0` for an f64 and `4` for an integer, which is the
|
|
1025
|
+
// same distinction, so the number's own JSON spelling is right.
|
|
1026
|
+
Value::Number(number) => number.to_string(),
|
|
1027
|
+
Value::String(text) => format!("{text:?}"),
|
|
1028
|
+
Value::Array(items) => format!("[{}]", items.iter().map(inspect).collect::<Vec<_>>().join(", ")),
|
|
1029
|
+
Value::Object(map) => format!(
|
|
1030
|
+
"{{{}}}",
|
|
1031
|
+
map.iter().map(|(key, value)| format!("{key:?} => {}", inspect(value))).collect::<Vec<_>>().join(", ")
|
|
1032
|
+
),
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
#[cfg(test)]
|
|
1037
|
+
mod tests {
|
|
1038
|
+
use super::*;
|
|
1039
|
+
|
|
1040
|
+
// Enough shape for every rule above to have something real to check against: a
|
|
1041
|
+
// lifecycle-less aggregate, a reference, a rows-shaped field, and a no-arg query.
|
|
1042
|
+
fn domain() -> Value {
|
|
1043
|
+
json!({
|
|
1044
|
+
"name": "Console",
|
|
1045
|
+
"aggregates": [
|
|
1046
|
+
{
|
|
1047
|
+
"name": "Event",
|
|
1048
|
+
"identified_by": ["slug.value"],
|
|
1049
|
+
"attributes": [
|
|
1050
|
+
{"name": "slug", "type": "Slug", "list": false},
|
|
1051
|
+
{"name": "name", "type": "EventName", "list": false},
|
|
1052
|
+
{"name": "price", "type": "Money", "list": false},
|
|
1053
|
+
{"name": "seats", "type": "Seat", "list": true},
|
|
1054
|
+
{"name": "organizer", "type": "Reference<Person>", "list": false}
|
|
1055
|
+
],
|
|
1056
|
+
"value_objects": [
|
|
1057
|
+
{"name": "Slug", "attributes": [{"name": "value", "type": "String"}]},
|
|
1058
|
+
{"name": "EventName", "attributes": [{"name": "value", "type": "String"}]},
|
|
1059
|
+
{"name": "Money", "attributes": [
|
|
1060
|
+
{"name": "cents", "type": "Integer"}, {"name": "currency", "type": "String"}]},
|
|
1061
|
+
{"name": "Seat", "attributes": [
|
|
1062
|
+
{"name": "row", "type": "String"}, {"name": "number", "type": "Integer"}]}
|
|
1063
|
+
],
|
|
1064
|
+
"entities": [],
|
|
1065
|
+
"commands": [],
|
|
1066
|
+
"queries": [
|
|
1067
|
+
{"name": "Everywhere", "attributes": []},
|
|
1068
|
+
{"name": "ByOrganizer", "attributes": [{"name": "organizer", "type": "Slug"}]}
|
|
1069
|
+
],
|
|
1070
|
+
"lifecycle": {"field": "status", "default": "open",
|
|
1071
|
+
"transitions": [{"command": "Close", "to_state": "closed", "from_state": "open"}]}
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
"name": "Person",
|
|
1075
|
+
"identified_by": ["email.value"],
|
|
1076
|
+
"attributes": [{"name": "email", "type": "Email", "list": false}],
|
|
1077
|
+
"value_objects": [{"name": "Email", "attributes": [{"name": "value", "type": "String"}]}],
|
|
1078
|
+
"entities": [], "commands": [], "queries": [], "lifecycle": null
|
|
1079
|
+
}
|
|
1080
|
+
]
|
|
1081
|
+
})
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// The smallest config this domain accepts: every real state styled.
|
|
1085
|
+
fn complete() -> Value {
|
|
1086
|
+
json!({"states": {"Event": {"open": {}, "closed": {}}}})
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
fn refusal(config: Value) -> String {
|
|
1090
|
+
validate(&config, &domain()).expect_err("this config should have been refused")
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
#[test]
|
|
1094
|
+
fn a_complete_config_validates() {
|
|
1095
|
+
assert!(validate(&complete(), &domain()).is_ok());
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
#[test]
|
|
1099
|
+
fn every_real_state_needs_an_entry_even_a_bare_one() {
|
|
1100
|
+
assert_eq!(
|
|
1101
|
+
refusal(json!({"states": {"Event": {"open": {}}}})),
|
|
1102
|
+
"no presentation entry for Event.closed — every real state needs one, even a bare one"
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
#[test]
|
|
1107
|
+
fn a_lifecycle_less_aggregate_is_never_reported_missing() {
|
|
1108
|
+
// Person declares no lifecycle at all — styling it is not a
|
|
1109
|
+
// thing to be missing, which is what keeps this check from
|
|
1110
|
+
// firing on every domain that has one such aggregate.
|
|
1111
|
+
assert!(validate(&complete(), &domain()).is_ok());
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
#[test]
|
|
1115
|
+
fn a_state_section_naming_an_aggregate_this_domain_does_not_declare_refuses() {
|
|
1116
|
+
assert_eq!(
|
|
1117
|
+
refusal(json!({"states": {"Ghost": {"gone": {}}}})),
|
|
1118
|
+
"Ghost is not a real aggregate this domain declares"
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
#[test]
|
|
1123
|
+
fn a_state_this_aggregate_does_not_declare_refuses_and_names_the_ones_it_does() {
|
|
1124
|
+
let mut config = complete();
|
|
1125
|
+
config["states"]["Event"]["cancelled"] = json!({});
|
|
1126
|
+
assert_eq!(refusal(config), "Event has no state \"cancelled\" — it declares open, closed");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
#[test]
|
|
1130
|
+
fn a_tone_outside_the_five_refuses_in_the_consoles_own_words() {
|
|
1131
|
+
let mut config = complete();
|
|
1132
|
+
config["states"]["Event"]["open"] = json!({"tone": "chartreuse"});
|
|
1133
|
+
assert_eq!(
|
|
1134
|
+
refusal(config),
|
|
1135
|
+
"Event.open's tone \"chartreuse\" isn't one of good, warn, danger, muted, accent"
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
#[test]
|
|
1140
|
+
fn attention_is_a_real_boolean_not_a_string() {
|
|
1141
|
+
let mut config = complete();
|
|
1142
|
+
config["states"]["Event"]["open"] = json!({"attention": "yes"});
|
|
1143
|
+
assert_eq!(refusal(config), "Event.open's attention must be true or false, not \"yes\"");
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
#[test]
|
|
1147
|
+
fn a_column_naming_a_field_the_aggregate_does_not_declare_refuses() {
|
|
1148
|
+
let mut config = complete();
|
|
1149
|
+
config["collections"] = json!({"Event": {"columns": [{"field": "nope"}]}});
|
|
1150
|
+
assert_eq!(
|
|
1151
|
+
refusal(config),
|
|
1152
|
+
"Event has no attribute \"nope\" to put in its columns — it declares slug, name, price, seats, organizer"
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
#[test]
|
|
1157
|
+
fn the_status_pill_is_a_column_every_aggregate_has() {
|
|
1158
|
+
let mut config = complete();
|
|
1159
|
+
config["collections"] = json!({"Event": {"columns": ["__state__", "slug"]}});
|
|
1160
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
#[test]
|
|
1164
|
+
fn a_columns_sortable_is_a_boolean_and_its_sort_default_is_one_of_two_words() {
|
|
1165
|
+
let mut config = complete();
|
|
1166
|
+
config["collections"] = json!({"Event": {"columns": [{"field": "slug", "sortable": "true"}]}});
|
|
1167
|
+
assert_eq!(refusal(config), "Event's column \"slug\" has sortable \"true\", not true or false");
|
|
1168
|
+
|
|
1169
|
+
let mut config = complete();
|
|
1170
|
+
config["collections"] = json!({"Event": {"columns": [{"field": "slug", "sort_default": "sideways"}]}});
|
|
1171
|
+
assert_eq!(refusal(config), "Event's column \"slug\" has sort_default \"sideways\", not asc or desc");
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
#[test]
|
|
1175
|
+
fn nav_order_is_a_number() {
|
|
1176
|
+
let mut config = complete();
|
|
1177
|
+
config["collections"] = json!({"Event": {"nav_order": "3"}});
|
|
1178
|
+
assert_eq!(refusal(config), "Event's nav_order must be a number, not \"3\"");
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
#[test]
|
|
1182
|
+
fn a_precondition_only_makes_sense_against_a_reference() {
|
|
1183
|
+
let mut config = complete();
|
|
1184
|
+
config["collections"] = json!({"Event": {"preconditions": [{"field": "slug", "state": "open"}]}});
|
|
1185
|
+
assert_eq!(refusal(config), "Event.slug isn't a reference — a precondition only makes sense against one");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
#[test]
|
|
1189
|
+
fn a_precondition_against_a_lifecycle_less_target_names_the_states_it_has() {
|
|
1190
|
+
let mut config = complete();
|
|
1191
|
+
config["collections"] = json!({"Event": {"preconditions": [{"field": "organizer", "state": "active"}]}});
|
|
1192
|
+
assert_eq!(refusal(config), "Event.organizer points at Person, which has no state \"active\" — it declares ");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
#[test]
|
|
1196
|
+
fn a_format_names_a_real_attribute_and_one_of_two_formats() {
|
|
1197
|
+
let mut config = complete();
|
|
1198
|
+
config["collections"] = json!({"Event": {"field_formats": {"nope": "date"}}});
|
|
1199
|
+
assert_eq!(refusal(config), "Event has no attribute \"nope\" to format");
|
|
1200
|
+
|
|
1201
|
+
let mut config = complete();
|
|
1202
|
+
config["collections"] = json!({"Event": {"field_formats": {"slug": "shouty"}}});
|
|
1203
|
+
assert_eq!(refusal(config), "Event.slug's format \"shouty\" isn't one of date, percent");
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
#[test]
|
|
1207
|
+
fn a_format_may_name_a_nested_compound_fields_own_attribute() {
|
|
1208
|
+
let mut config = complete();
|
|
1209
|
+
config["collections"] = json!({"Event": {"field_formats": {"cents": "percent"}}});
|
|
1210
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
#[test]
|
|
1214
|
+
fn an_identity_rule_names_a_plain_field_and_one_of_three_strategies() {
|
|
1215
|
+
let mut config = complete();
|
|
1216
|
+
config["collections"] = json!({"Event": {"identity": {"field": "organizer", "strategy": "slug"}}});
|
|
1217
|
+
assert_eq!(refusal(config), "Event.organizer is a reference — identity can only derive a plain field");
|
|
1218
|
+
|
|
1219
|
+
let mut config = complete();
|
|
1220
|
+
config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "guesswork"}}});
|
|
1221
|
+
assert_eq!(refusal(config), "Event's identity strategy \"guesswork\" isn't 'slug', 'sequence', or 'port'");
|
|
1222
|
+
|
|
1223
|
+
let mut config = complete();
|
|
1224
|
+
config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "slug"}}});
|
|
1225
|
+
assert_eq!(refusal(config), "Event's identity slug strategy needs a source field");
|
|
1226
|
+
|
|
1227
|
+
let mut config = complete();
|
|
1228
|
+
config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "sequence"}}});
|
|
1229
|
+
assert_eq!(refusal(config), "Event's identity sequence strategy needs a prefix");
|
|
1230
|
+
|
|
1231
|
+
let mut config = complete();
|
|
1232
|
+
config["collections"] =
|
|
1233
|
+
json!({"Event": {"identity": {"field": "slug", "strategy": "slug", "source": "name"}}});
|
|
1234
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
#[test]
|
|
1238
|
+
fn a_detail_fields_display_is_one_of_five_placements() {
|
|
1239
|
+
let mut config = complete();
|
|
1240
|
+
config["collections"] = json!({"Event": {"detail_fields": [{"field": "name", "display": "interpretive"}]}});
|
|
1241
|
+
assert_eq!(
|
|
1242
|
+
refusal(config),
|
|
1243
|
+
"Event's detail field \"name\" has display \"interpretive\", not one of simple, prose, bullet, mono, rows"
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
#[test]
|
|
1248
|
+
fn row_columns_are_checked_against_the_rows_shape_the_field_actually_holds() {
|
|
1249
|
+
let mut config = complete();
|
|
1250
|
+
config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "columns": ["row"]}]}});
|
|
1251
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1252
|
+
|
|
1253
|
+
let mut config = complete();
|
|
1254
|
+
config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "columns": ["aisle"]}]}});
|
|
1255
|
+
assert_eq!(refusal(config), "Event's detail field \"seats\" has no row column \"aisle\" — it has row, number");
|
|
1256
|
+
|
|
1257
|
+
let mut config = complete();
|
|
1258
|
+
config["collections"] = json!({"Event": {"detail_fields": [{"field": "name", "columns": ["row"]}]}});
|
|
1259
|
+
assert_eq!(refusal(config), "Event's detail field \"name\" has columns: but isn't a rows-shaped field");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
#[test]
|
|
1263
|
+
fn a_state_filter_needs_an_entity_backed_rows_field() {
|
|
1264
|
+
let mut config = complete();
|
|
1265
|
+
config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "state_filter": "taken"}]}});
|
|
1266
|
+
assert_eq!(
|
|
1267
|
+
refusal(config),
|
|
1268
|
+
"Event's detail field \"seats\" has state_filter: but isn't an entity-backed rows field"
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
#[test]
|
|
1273
|
+
fn a_list_query_is_one_of_this_aggregates_own_no_arg_queries() {
|
|
1274
|
+
let mut config = complete();
|
|
1275
|
+
config["collections"] = json!({"Event": {"list_query": "Everywhere"}});
|
|
1276
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1277
|
+
|
|
1278
|
+
let mut config = complete();
|
|
1279
|
+
config["collections"] = json!({"Event": {"list_query": "Nowhere"}});
|
|
1280
|
+
assert_eq!(refusal(config), "Event has no query \"Nowhere\" to use as its list_query");
|
|
1281
|
+
|
|
1282
|
+
let mut config = complete();
|
|
1283
|
+
config["collections"] = json!({"Event": {"list_query": "ByOrganizer"}});
|
|
1284
|
+
assert_eq!(
|
|
1285
|
+
refusal(config),
|
|
1286
|
+
"Event's list_query \"ByOrganizer\" takes its own arguments (organizer) — \
|
|
1287
|
+
a generic table view has nowhere to get them from"
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
#[test]
|
|
1292
|
+
fn after_create_is_validated_in_full_and_then_refused_as_unstorable() {
|
|
1293
|
+
let mut config = complete();
|
|
1294
|
+
config["collections"] = json!({"Event": {"after_create": {"dispatch": "Ghost.Add"}}});
|
|
1295
|
+
assert_eq!(
|
|
1296
|
+
refusal(config),
|
|
1297
|
+
"Event's after_create dispatches \"Ghost.Add\", but \"Ghost\" is not a real aggregate this domain declares"
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
// A well-formed one still refuses — with the scope-boundary
|
|
1301
|
+
// message, not a shape complaint.
|
|
1302
|
+
let mut config = complete();
|
|
1303
|
+
config["collections"] = json!({"Event": {"after_create": {"dispatch": "Person.Nope"}}});
|
|
1304
|
+
assert_eq!(
|
|
1305
|
+
refusal(config),
|
|
1306
|
+
"Event's after_create dispatches \"Person.Nope\", but Person declares no command \"Nope\""
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
#[test]
|
|
1311
|
+
fn a_storable_after_create_still_refuses_because_nothing_stores_it_yet() {
|
|
1312
|
+
let mut ir = domain();
|
|
1313
|
+
ir["aggregates"][1]["commands"] = json!([{"name": "Note", "references": "Person", "attributes": []}]);
|
|
1314
|
+
let mut config = complete();
|
|
1315
|
+
config["collections"] = json!({"Event": {"after_create": {"dispatch": "Person.Note"}}});
|
|
1316
|
+
assert_eq!(
|
|
1317
|
+
validate(&config, &ir).expect_err("still unstorable"),
|
|
1318
|
+
"Event's after_create isn't stored by ConsoleSettings yet \
|
|
1319
|
+
(see console_settings.bluebook's own header) — remove it before saving"
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
#[test]
|
|
1324
|
+
fn an_overview_stat_names_a_real_collection_key_and_a_real_fold() {
|
|
1325
|
+
let mut config = complete();
|
|
1326
|
+
config["overview"] = json!({"stats": [{"label": "Open", "collection": "nowhere"}]});
|
|
1327
|
+
assert_eq!(refusal(config), "overview stat names collection \"nowhere\", which isn't a real collection key");
|
|
1328
|
+
|
|
1329
|
+
let mut config = complete();
|
|
1330
|
+
config["overview"] = json!({"stats": [{"label": "Open", "collection": "events", "as": "vibes"}]});
|
|
1331
|
+
assert_eq!(refusal(config), "overview stat \"Open\"'s as: \"vibes\" isn't one of count, money_sum");
|
|
1332
|
+
|
|
1333
|
+
let mut config = complete();
|
|
1334
|
+
config["overview"] = json!({"stats": [{"label": "Take", "collection": "events", "as": "money_sum"}]});
|
|
1335
|
+
assert_eq!(refusal(config), "overview stat \"Take\" folds as money_sum but names no field");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
#[test]
|
|
1339
|
+
fn a_stats_where_clause_is_walked_including_through_a_negation() {
|
|
1340
|
+
let mut config = complete();
|
|
1341
|
+
config["overview"] =
|
|
1342
|
+
json!({"stats": [{"label": "Live", "collection": "events", "where": {"state": "ajar"}}]});
|
|
1343
|
+
assert_eq!(
|
|
1344
|
+
refusal(config),
|
|
1345
|
+
"overview stat \"Live\"'s where: state \"ajar\" isn't one of Event's own states (open, closed)"
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
let mut config = complete();
|
|
1349
|
+
config["overview"] =
|
|
1350
|
+
json!({"stats": [{"label": "Live", "collection": "events", "where": {"not": {"state": {"eq": "ajar"}}}}]});
|
|
1351
|
+
assert_eq!(
|
|
1352
|
+
refusal(config),
|
|
1353
|
+
"overview stat \"Live\"'s where: state \"ajar\" isn't one of Event's own states (open, closed)"
|
|
1354
|
+
);
|
|
1355
|
+
|
|
1356
|
+
let mut config = complete();
|
|
1357
|
+
config["overview"] =
|
|
1358
|
+
json!({"stats": [{"label": "Live", "collection": "events", "where": {"state": {"in": ["open"]}}}]});
|
|
1359
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
#[test]
|
|
1363
|
+
fn has_related_names_a_real_collection_and_a_real_field_on_it() {
|
|
1364
|
+
let mut config = complete();
|
|
1365
|
+
config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
|
|
1366
|
+
"where": {"has_related": {"collection": "persons", "field": "nope"}}}]});
|
|
1367
|
+
assert_eq!(refusal(config), "overview stat \"Hosted\"'s has_related.field \"nope\" isn't a real field on Person");
|
|
1368
|
+
|
|
1369
|
+
let mut config = complete();
|
|
1370
|
+
config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
|
|
1371
|
+
"where": {"has_related": {"collection": "persons", "field": "email", "equals": "nope"}}}]});
|
|
1372
|
+
assert_eq!(
|
|
1373
|
+
refusal(config),
|
|
1374
|
+
"overview stat \"Hosted\"'s has_related.equals \"nope\" isn't a real field on Event"
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
let mut config = complete();
|
|
1378
|
+
config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
|
|
1379
|
+
"where": {"has_related": {"collection": "persons", "field": "email", "equals": "slug"}}}]});
|
|
1380
|
+
assert!(validate(&config, &domain()).is_ok());
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
#[test]
|
|
1384
|
+
fn a_bare_column_name_and_a_descriptor_both_dispatch_as_a_row() {
|
|
1385
|
+
assert_eq!(
|
|
1386
|
+
columns_for_dispatch(Some(&json!(["slug", {"field": "name", "sortable": true, "head": "Title"}]))),
|
|
1387
|
+
vec![
|
|
1388
|
+
json!({"field": "slug"}),
|
|
1389
|
+
json!({"field": "name", "sortable": "true", "extra_json": "{\"head\":\"Title\"}"})
|
|
1390
|
+
]
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
#[test]
|
|
1395
|
+
fn a_rows_fields_sub_columns_flatten_to_one_row_per_pair() {
|
|
1396
|
+
assert_eq!(
|
|
1397
|
+
detail_field_columns_for_dispatch(Some(&json!([
|
|
1398
|
+
{"field": "seats", "columns": ["row", "number"]},
|
|
1399
|
+
{"field": "name"}
|
|
1400
|
+
]))),
|
|
1401
|
+
vec![
|
|
1402
|
+
json!({"field": "seats", "column": "row"}),
|
|
1403
|
+
json!({"field": "seats", "column": "number"})
|
|
1404
|
+
]
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
#[test]
|
|
1409
|
+
fn field_formats_go_from_a_map_to_rows_and_back() {
|
|
1410
|
+
assert_eq!(
|
|
1411
|
+
field_formats_for_dispatch(Some(&json!({"price": "percent"}))),
|
|
1412
|
+
vec![json!({"field": "price", "format": "percent"})]
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
#[test]
|
|
1417
|
+
fn a_stats_where_clause_rides_through_as_opaque_json_text() {
|
|
1418
|
+
let stats = stats_for_dispatch(Some(&json!([{"label": "Live", "collection": "events",
|
|
1419
|
+
"where": {"state": "open"}}])));
|
|
1420
|
+
assert_eq!(stats[0]["where_json"], json!("{\"state\":\"open\"}"));
|
|
1421
|
+
assert!(stats[0].get("as").is_none(), "an absent fold is absent, not null: {}", stats[0]);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
#[test]
|
|
1425
|
+
fn inspect_prints_the_way_ruby_prints() {
|
|
1426
|
+
assert_eq!(inspect(&json!("warn")), "\"warn\"");
|
|
1427
|
+
assert_eq!(inspect(&Value::Null), "nil");
|
|
1428
|
+
assert_eq!(inspect(&json!(true)), "true");
|
|
1429
|
+
assert_eq!(inspect(&json!(3)), "3");
|
|
1430
|
+
}
|
|
1431
|
+
}
|