hecks 3.0.4 → 3.0.5
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 +7 -1
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/project_cli.rb +37 -8
- data/lib/hecks/cli/seed_semantics_corpus.rb +35 -6
- data/lib/hecks/doors/launcher_options.rb +18 -0
- data/lib/hecks/gate/stages.yml +4 -0
- data/lib/hecks/hecks/hecks.world +5 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projector/cli_projector.rb +63 -4
- data/lib/hecks/runtime/command_interpreter.rb +2 -0
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/invocation.rb +5 -3
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16dca2d33433ad6c6cd98b8dee40f652df3d84c7bb93df5cd1f7734b99542c08
|
|
4
|
+
data.tar.gz: '084b92e2ad954b89a488d44997d754cd15388136f502f918f24274fd26cf9e87'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f2b6dda10ee042c6cb1bbe3ae9100a66c35dad25b17a031ab7ca6fd0eaf7ea5960b1a3e52024d5a40c3090ca999f1730db9275f7a5ec8b803e8ae0a465102df
|
|
7
|
+
data.tar.gz: 8aebabbb0a023e9c2ff989df18f665924ec9acf4043a922bcb995588fb424683d8d44bea2e02f2076932beae2b27d63da097153c054131aa4232c132123d33eb
|
data/exe/hecks
CHANGED
|
@@ -21,6 +21,11 @@ if LEGACY.include?(ARGV.first)
|
|
|
21
21
|
exit Hecks::CLI.start(ARGV) unless Hecks::CLI.launcher_form?(ARGV)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# These verbs keep nothing worth a database, so they run on Memory unless told otherwise, and
|
|
25
|
+
# print nothing when they end well.
|
|
26
|
+
MEMORY_VERBS = %w[console].freeze
|
|
27
|
+
ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_VERBS.include?(ARGV.first)
|
|
28
|
+
|
|
24
29
|
require "hecks"
|
|
25
30
|
|
|
26
31
|
# Usage is answered from the projected chapter alone: no adapter is bound and no
|
|
@@ -39,7 +44,7 @@ text, status, reason = Hecks::Doors::CliRunner.usage(
|
|
|
39
44
|
)
|
|
40
45
|
unless text
|
|
41
46
|
runtime = begin
|
|
42
|
-
Hecks.boot_described(described, install_doors: false)
|
|
47
|
+
Hecks::Doors::LauncherOptions.quietly(hold: MEMORY_VERBS.include?(ARGV.first)) { Hecks.boot_described(described, install_doors: false) }
|
|
43
48
|
rescue StandardError => e
|
|
44
49
|
abort "cannot open Hecks: #{e.message.lines.first.strip}"
|
|
45
50
|
end
|
|
@@ -57,4 +62,5 @@ if reason
|
|
|
57
62
|
warn reason
|
|
58
63
|
exit status
|
|
59
64
|
end
|
|
65
|
+
exit 0 if status.zero? && MEMORY_VERBS.include?(ARGV.first)
|
|
60
66
|
status.zero? ? puts(text) : abort(text)
|
data/lib/hecks/cli/console.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Hecks
|
|
|
9
9
|
# The command behind `hecks console` and Custodian's `Operation.OpenConsole`: boots a domain
|
|
10
10
|
# (pizzas on Memory by default) and drops into IRB with its door installed, for dispatching a
|
|
11
11
|
# real command by hand. No argument needs no database (ADR 0073); a domain directory boots as
|
|
12
|
-
# it is wired, which may bind PostgresEra.
|
|
12
|
+
# it is wired, which may bind PostgresEra (examples/directory does).
|
|
13
13
|
module Console
|
|
14
14
|
PIZZAS = File.expand_path("../../../examples/pizzas", __dir__)
|
|
15
15
|
|
|
@@ -25,14 +25,27 @@ module Hecks
|
|
|
25
25
|
# @param domain [String, nil] a domain directory; the bundled pizzas domain when nil
|
|
26
26
|
# @param launcher [#call] starts the interactive session; IRB unless a caller stands in
|
|
27
27
|
# @return [Runtime] the runtime the session ran against
|
|
28
|
-
def call(domain = nil, launcher: -> {
|
|
28
|
+
def call(domain = nil, launcher: -> { irb_session })
|
|
29
29
|
runtime = domain ? Hecks.boot(domain) : Hecks.boot_files(DEFAULT_FILES)
|
|
30
30
|
puts overview(runtime)
|
|
31
|
-
puts banner
|
|
31
|
+
puts banner(domain)
|
|
32
32
|
launcher.call
|
|
33
33
|
runtime
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Starts IRB with an empty command line. IRB reads `ARGV` for a script to run, and a
|
|
37
|
+
# launcher's `ARGV` holds its own words (`console`, `subject=<domain>`), which IRB would
|
|
38
|
+
# open as a file and then exit without a prompt.
|
|
39
|
+
#
|
|
40
|
+
# @return [void] when the session ends; `ARGV` is restored
|
|
41
|
+
def irb_session
|
|
42
|
+
words = ARGV.dup
|
|
43
|
+
ARGV.clear
|
|
44
|
+
IRB.start(__FILE__)
|
|
45
|
+
ensure
|
|
46
|
+
ARGV.replace(words)
|
|
47
|
+
end
|
|
48
|
+
|
|
36
49
|
# @param runtime [Runtime] a booted domain
|
|
37
50
|
# @return [String] each chapter's vision and its aggregates' commands
|
|
38
51
|
def overview(runtime)
|
|
@@ -46,16 +59,19 @@ module Hecks
|
|
|
46
59
|
end.join
|
|
47
60
|
end
|
|
48
61
|
|
|
49
|
-
# @
|
|
50
|
-
|
|
62
|
+
# @param domain [String, nil] a domain directory, or nil for the bundled pizzas domain
|
|
63
|
+
# @return [String] the version, and for the pizzas domain a first session to try
|
|
64
|
+
def banner(domain = nil)
|
|
65
|
+
return "\nhecks #{Hecks::VERSION}\n\n" if domain
|
|
66
|
+
|
|
51
67
|
<<~BANNER
|
|
52
68
|
|
|
53
69
|
hecks #{Hecks::VERSION}
|
|
54
70
|
|
|
55
71
|
try:
|
|
56
|
-
order = Order.create_pizza!(name:
|
|
57
|
-
order.add_topping!(topping:
|
|
58
|
-
order.purchase!(customer_name:
|
|
72
|
+
order = Order.create_pizza!(name: "Margherita", pizza: { price_cents: { cents: 1200 }, size: "large" })
|
|
73
|
+
order.add_topping!(topping: "Basil", amount: 3)
|
|
74
|
+
order.purchase!(customer_name: "Chris", amount: { cents: 1200 })
|
|
59
75
|
order.status
|
|
60
76
|
order.events.last
|
|
61
77
|
|
|
@@ -103,7 +103,7 @@ module Hecks
|
|
|
103
103
|
label = executable || "#{path}/#{snake}"
|
|
104
104
|
file = File.join(root, label)
|
|
105
105
|
text = launcher(path, name, program, executable: executable, legacy: setting[:legacy],
|
|
106
|
-
opted: !setting.empty?)
|
|
106
|
+
memory_verbs: setting[:memory_verbs], opted: !setting.empty?)
|
|
107
107
|
return skip(path, "#{label} is a directory") if File.directory?(file)
|
|
108
108
|
|
|
109
109
|
return :drifted if check && !(File.file?(file) && File.read(file) == text)
|
|
@@ -150,10 +150,11 @@ module Hecks
|
|
|
150
150
|
# @param executable [String, nil] the file's path under the root when it is not beside the
|
|
151
151
|
# domain; its program name is then that file's basename, and its header names the generator
|
|
152
152
|
# @param legacy [Array<String>, nil] verbs an executable hands to `Hecks::CLI` first
|
|
153
|
+
# @param memory_verbs [Array<String>, nil] verbs run on Memory unless HECKS_ENVIRONMENT is set
|
|
153
154
|
# @param opted [Boolean] whether the chapter's world declares a `launcher` setting
|
|
154
155
|
# @return [String] the Ruby source
|
|
155
156
|
# @raise [ArgumentError] if a name, path, executable or legacy verb is not plain
|
|
156
|
-
def launcher(path, name, program, executable: nil, legacy: nil, opted: !executable.nil?)
|
|
157
|
+
def launcher(path, name, program, executable: nil, legacy: nil, memory_verbs: nil, opted: !executable.nil?)
|
|
157
158
|
plain!("chapter name", name, NAME)
|
|
158
159
|
plain!("domain path", path, PATH)
|
|
159
160
|
snake = Naming.snake(name)
|
|
@@ -174,9 +175,10 @@ module Hecks
|
|
|
174
175
|
where = shown = "#{path}/#{snake}"
|
|
175
176
|
end
|
|
176
177
|
|
|
177
|
-
handoff = legacy_handoff(Array(legacy)) if executable
|
|
178
|
+
handoff = legacy_handoff(Array(legacy)) + memory_default(Array(memory_verbs)) if executable
|
|
178
179
|
encoding = opted ? ENCODING : ""
|
|
179
180
|
ending = opted ? OPTED_ENDING : PLAIN_ENDING
|
|
181
|
+
ending = quiet_ending(ending) if executable && !Array(memory_verbs).empty?
|
|
180
182
|
|
|
181
183
|
<<~RUBY
|
|
182
184
|
#!/usr/bin/env ruby
|
|
@@ -194,7 +196,7 @@ module Hecks
|
|
|
194
196
|
#{handoff}
|
|
195
197
|
require "hecks"
|
|
196
198
|
|
|
197
|
-
#{entry(name, boot, shown, opted)}
|
|
199
|
+
#{entry(name, boot, shown, opted, quiet: !Array(memory_verbs).empty?)}
|
|
198
200
|
#{ending}
|
|
199
201
|
RUBY
|
|
200
202
|
end
|
|
@@ -203,13 +205,15 @@ module Hecks
|
|
|
203
205
|
# @return [String] the launcher's middle: for an opted-in launcher, usage answered from the
|
|
204
206
|
# projection and a boot only for a line that runs a verb; for any other, the boot and
|
|
205
207
|
# dispatch every launcher has always had, byte for byte
|
|
206
|
-
def entry(name, boot, shown, opted)
|
|
207
|
-
opted ? described_entry(name, boot, shown) : plain_entry(name, boot, shown)
|
|
208
|
+
def entry(name, boot, shown, opted, quiet: false)
|
|
209
|
+
opted ? described_entry(name, boot, shown, quiet: quiet) : plain_entry(name, boot, shown)
|
|
208
210
|
end
|
|
209
211
|
|
|
210
212
|
# @api private
|
|
211
213
|
# @return [String] the opted-in launcher's middle
|
|
212
|
-
def described_entry(name, boot, shown)
|
|
214
|
+
def described_entry(name, boot, shown, quiet: false)
|
|
215
|
+
started = "Hecks.boot_described(described, install_doors: false)"
|
|
216
|
+
started = "Hecks::Doors::LauncherOptions.quietly(hold: MEMORY_VERBS.include?(ARGV.first)) { #{started} }" if quiet
|
|
213
217
|
<<~RUBY.chomp
|
|
214
218
|
# Usage is answered from the projected chapter alone: no adapter is bound and no
|
|
215
219
|
# database is opened. Only a line that runs a verb boots the domain,
|
|
@@ -227,7 +231,7 @@ module Hecks
|
|
|
227
231
|
)
|
|
228
232
|
unless text
|
|
229
233
|
runtime = begin
|
|
230
|
-
|
|
234
|
+
#{started}
|
|
231
235
|
rescue StandardError => e
|
|
232
236
|
abort "cannot open #{name}: \#{e.message.lines.first.strip}"
|
|
233
237
|
end
|
|
@@ -264,6 +268,31 @@ module Hecks
|
|
|
264
268
|
raise ArgumentError, "#{what} #{value.to_s.inspect} is not a plain word or path"
|
|
265
269
|
end
|
|
266
270
|
|
|
271
|
+
# @api private
|
|
272
|
+
# @param ending [String] the launcher's closing lines
|
|
273
|
+
# @return [String] the same lines, except that a memory verb that ended well prints nothing: a
|
|
274
|
+
# person is at it, so the settled record is noise after the session
|
|
275
|
+
def quiet_ending(ending)
|
|
276
|
+
ending.sub("status.zero? ? puts(text)",
|
|
277
|
+
"exit 0 if status.zero? && MEMORY_VERBS.include?(ARGV.first)\nstatus.zero? ? puts(text)")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @api private
|
|
281
|
+
# @return [String] the lines that default the listed verbs to Memory, or nothing
|
|
282
|
+
def memory_default(verbs)
|
|
283
|
+
return "" if verbs.empty?
|
|
284
|
+
|
|
285
|
+
verbs.each { |verb| plain!("memory verb", verb, VERB) }
|
|
286
|
+
|
|
287
|
+
<<~RUBY
|
|
288
|
+
|
|
289
|
+
# These verbs keep nothing worth a database, so they run on Memory unless told otherwise, and
|
|
290
|
+
# print nothing when they end well.
|
|
291
|
+
MEMORY_VERBS = %w[#{verbs.join(' ')}].freeze
|
|
292
|
+
ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_VERBS.include?(ARGV.first)
|
|
293
|
+
RUBY
|
|
294
|
+
end
|
|
295
|
+
|
|
267
296
|
# @api private
|
|
268
297
|
# @return [String] the lines that hand the legacy verbs to `Hecks::CLI`, or nothing
|
|
269
298
|
def legacy_handoff(legacy)
|
|
@@ -7,9 +7,12 @@ require_relative "../fuzzing"
|
|
|
7
7
|
module Hecks
|
|
8
8
|
module CLI
|
|
9
9
|
# The command behind `hecks seed_semantics_corpus`: seeds (or deliberately re-seeds) the
|
|
10
|
-
# `expect` block of
|
|
10
|
+
# `expect` block of corpus fixtures from the Ruby runtime. Review the result
|
|
11
11
|
# against `docs/semantics/bluebook-semantics.md` before committing.
|
|
12
12
|
#
|
|
13
|
+
# Seeds `spec/corpus/semantics/` and the conformance corpus ({FULL_SCRIPTS} plus
|
|
14
|
+
# `spec/corpus/rust_conformance/`, whose `expect` also freezes more).
|
|
15
|
+
#
|
|
13
16
|
# hecks seed_semantics_corpus # fill fixtures missing an expect
|
|
14
17
|
# SEED=refusal_kind_lifecycle hecks seed_semantics_corpus # re-seed one, deliberately
|
|
15
18
|
#
|
|
@@ -18,6 +21,15 @@ module Hecks
|
|
|
18
21
|
module SeedSemanticsCorpus
|
|
19
22
|
module_function
|
|
20
23
|
|
|
24
|
+
# Full corpus scripts held to the conformance bar, keyed by file, valued by domain.
|
|
25
|
+
FULL_SCRIPTS = {
|
|
26
|
+
"spec/corpus/banking.json" => "examples/banking",
|
|
27
|
+
"spec/corpus/chess.json" => "examples/chess"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# The `expect` keys a conformance fixture freezes beyond the semantics three.
|
|
31
|
+
CONFORMANCE_KEYS = %w[queries sagas dry_runs reactions].freeze
|
|
32
|
+
|
|
21
33
|
# Seeds the fixtures that need it and reports which.
|
|
22
34
|
#
|
|
23
35
|
# @param root [String] the checkout whose `spec/corpus/semantics/` is seeded
|
|
@@ -26,12 +38,13 @@ module Hecks
|
|
|
26
38
|
# @return [Integer] the exit status, always 0
|
|
27
39
|
def call(root:, env: ENV, out: $stdout)
|
|
28
40
|
only = env.fetch("SEED", nil)
|
|
29
|
-
seeded =
|
|
41
|
+
seeded = corpus_paths(root).filter_map do |path, domain, full|
|
|
30
42
|
fixture = JSON.parse(File.read(path))
|
|
31
43
|
name = File.basename(path, ".json")
|
|
32
44
|
next if only ? only != name : fixture.key?("expect")
|
|
33
45
|
|
|
34
|
-
fixture["
|
|
46
|
+
fixture["domain"] ||= domain if domain
|
|
47
|
+
fixture["expect"] = expectation_for(root, fixture, full: full)
|
|
35
48
|
File.write(path, "#{JSON.pretty_generate(fixture)}\n")
|
|
36
49
|
name
|
|
37
50
|
end
|
|
@@ -39,19 +52,35 @@ module Hecks
|
|
|
39
52
|
0
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
# @param root [String] the checkout
|
|
56
|
+
# @return [Array<Array>] `[path, domain_to_add_or_nil, full_expect?]` for every seedable file
|
|
57
|
+
def corpus_paths(root)
|
|
58
|
+
semantics = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).map { |p| [p, nil, false] }
|
|
59
|
+
conformance = Dir.glob(File.join(root, "spec/corpus/rust_conformance", "*.json")).map { |p| [p, nil, true] }
|
|
60
|
+
scripts = FULL_SCRIPTS.map { |file, domain| [File.join(root, file), domain, true] }
|
|
61
|
+
semantics + conformance + scripts
|
|
62
|
+
end
|
|
63
|
+
|
|
42
64
|
# Replays the fixture's script against the Ruby runtime. Each refusal's `"kind"` is reduced
|
|
43
65
|
# to its class name; `occurred_at` is stripped from events.
|
|
44
66
|
#
|
|
45
67
|
# @param root [String] the checkout the fixture's domain path is relative to
|
|
46
68
|
# @param fixture [Hash] the parsed fixture: `domain` and `steps`
|
|
47
|
-
# @
|
|
48
|
-
|
|
69
|
+
# @param full [Boolean] also freeze queries, sagas, dry runs, reactions (conformance)
|
|
70
|
+
# @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events, and
|
|
71
|
+
# with `full` the {CONFORMANCE_KEYS}
|
|
72
|
+
def expectation_for(root, fixture, full: false)
|
|
49
73
|
result = Hecks::Fuzzing::Replay.call(File.join(root, fixture["domain"]), fixture["steps"])
|
|
50
|
-
{
|
|
74
|
+
expect = {
|
|
51
75
|
"refusals" => JSON.parse(JSON.generate(result[:refusals])).each { |r| r["kind"] = r["kind"]&.split("::")&.last },
|
|
52
76
|
"instances" => JSON.parse(JSON.generate(result[:instances])),
|
|
53
77
|
"events" => JSON.parse(JSON.generate(result[:events])).each { |e| e.delete("occurred_at") }
|
|
54
78
|
}
|
|
79
|
+
return expect unless full
|
|
80
|
+
|
|
81
|
+
# instances_at is Replay's per-query snapshot for the property harness, not an outcome.
|
|
82
|
+
expect["queries"] = JSON.parse(JSON.generate(result[:queries].map { |q| q.except(:instances_at) }))
|
|
83
|
+
expect.merge!(CONFORMANCE_KEYS.drop(1).to_h { |k| [k, JSON.parse(JSON.generate(result[k.to_sym]))] })
|
|
55
84
|
end
|
|
56
85
|
|
|
57
86
|
# @param out [IO] where the report goes
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "stringio"
|
|
1
2
|
require_relative "../ports/identity_generation"
|
|
2
3
|
require_relative "../runtime/errors"
|
|
3
4
|
require_relative "cli_door"
|
|
@@ -46,6 +47,23 @@ module Hecks
|
|
|
46
47
|
{ program: program, names: settings && settings[:names], mint_run_keys: settings && settings[:run_keys] }
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# Runs the block with standard error held back, so the wiring notes hecks prints about its own
|
|
51
|
+
# chapters (a Memory journal loses sagas on restart) do not open a console session. They are
|
|
52
|
+
# for an operator running a stored journal. A raised error still surfaces, unchanged.
|
|
53
|
+
#
|
|
54
|
+
# @param hold [Boolean] whether to hold standard error back; false runs the block as it is
|
|
55
|
+
# @yield the boot
|
|
56
|
+
# @return [Object] the block's value
|
|
57
|
+
def quietly(hold: true)
|
|
58
|
+
return yield unless hold
|
|
59
|
+
|
|
60
|
+
shown = $stderr
|
|
61
|
+
$stderr = StringIO.new
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
$stderr = shown if hold
|
|
65
|
+
end
|
|
66
|
+
|
|
49
67
|
# Takes `--wait` out of a verb's words, unless the verb declares a `wait` argument of its own.
|
|
50
68
|
#
|
|
51
69
|
# `--wait=false` (or `no`, `0`, `off`) is a `--wait` that was switched off; a bare `--wait`
|
data/lib/hecks/gate/stages.yml
CHANGED
|
@@ -45,6 +45,10 @@ pre_push:
|
|
|
45
45
|
title: codegen drift, the committed rust/src/generated/ must match a fresh regeneration
|
|
46
46
|
run: [bundle, exec, exe/hecks, regenerate_corpus, --check, --wait]
|
|
47
47
|
blocked: regenerating hits a diff, so the committed Rust is stale; run `hecks regenerate_corpus --confirm` and commit the result.
|
|
48
|
+
- id: comment_style
|
|
49
|
+
title: comment style, every comment under lib/hecks against docs/COMMENT_STYLE_GUIDE.md
|
|
50
|
+
run: [bundle, exec, exe/hecks, check_comments, paths=lib/hecks, --wait]
|
|
51
|
+
blocked: a comment breaks the style guide, such as a line past 100 characters; `hecks fix_comments` rewrites the mechanical ones, and the rest are shortened by hand. CI runs this same check.
|
|
48
52
|
- id: comment_blocks
|
|
49
53
|
title: comment blocks, no run of comment lines past ADR 0075's cap
|
|
50
54
|
run: [bundle, exec, exe/hecks, check_comments, "paths=lib,spec,examples", only=long_block, --wait]
|
data/lib/hecks/hecks/hecks.world
CHANGED
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
# `failure_states` are the lifecycle states `--wait` reports as exit 1 (every failure state the
|
|
27
27
|
# chapter's lifecycles declare), `names` gives a command the launcher name clients type,
|
|
28
28
|
# `executable` is where `hecks project_cli` writes this chapter's launcher (the gem's
|
|
29
|
-
# `exe/hecks`), `streams` lists the questions `--stream` may tail (`Follow`),
|
|
30
|
-
# the verbs that launcher
|
|
29
|
+
# `exe/hecks`), `streams` lists the questions `--stream` may tail (`Follow`), `memory_verbs` names
|
|
30
|
+
# the verbs that launcher runs on the Memory environment unless `HECKS_ENVIRONMENT` is set (so
|
|
31
|
+
# `hecks console` needs no database), and `legacy` names the verbs it hands to `Hecks::CLI` in the
|
|
32
|
+
# positional forms ADR 0066 shipped.
|
|
31
33
|
Hecks.world "Hecks" do
|
|
32
34
|
realm "Hecks"
|
|
33
35
|
default_adapter "PostgresEra"
|
|
@@ -38,6 +40,7 @@ Hecks.world "Hecks" do
|
|
|
38
40
|
names: { "mcp" => "serve_mcp", "console" => "open_console" },
|
|
39
41
|
streams: %w[Follow],
|
|
40
42
|
executable: "exe/hecks",
|
|
43
|
+
memory_verbs: %w[console],
|
|
41
44
|
legacy: %w[run docs narrate ir stores model_check smoke_test
|
|
42
45
|
project_diagrams project_cli mcp]
|
|
43
46
|
end
|
|
@@ -123,7 +123,8 @@ module Hecks
|
|
|
123
123
|
raise unless defined?(PG::Error) && e.is_a?(PG::Error)
|
|
124
124
|
|
|
125
125
|
raise Runtime::WiringError,
|
|
126
|
-
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
|
|
126
|
+
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip} " \
|
|
127
|
+
"-- to run without a database, set HECKS_ENVIRONMENT=memory (a domain needs an environments/memory overlay)"
|
|
127
128
|
end
|
|
128
129
|
|
|
129
130
|
# Joins the process's shared connection for the declared database and schema, so every
|
|
@@ -206,6 +206,7 @@ module Hecks
|
|
|
206
206
|
|
|
207
207
|
{ verb: fqn(bluebook, aggregate, command, entity), kind: :command,
|
|
208
208
|
summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
|
|
209
|
+
group: aggregate.hecks_name, internal: command.role.to_s == "System",
|
|
209
210
|
creates: command.creates?,
|
|
210
211
|
receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
|
|
211
212
|
legacy_arguments: legacy_arguments,
|
|
@@ -230,7 +231,7 @@ module Hecks
|
|
|
230
231
|
else
|
|
231
232
|
"#{port.name} telling #{aggregate.hecks_name}"
|
|
232
233
|
end,
|
|
233
|
-
role_gated: false,
|
|
234
|
+
role_gated: false, group: aggregate.hecks_name, internal: true,
|
|
234
235
|
summary: port_summary(port, operation), arguments: arguments }
|
|
235
236
|
end
|
|
236
237
|
|
|
@@ -257,13 +258,30 @@ module Hecks
|
|
|
257
258
|
summary: model.description, arguments: arguments }
|
|
258
259
|
end
|
|
259
260
|
|
|
261
|
+
# A question that only reads the aggregate's own records back: it returns no document and
|
|
262
|
+
# filters on nothing but the record's identity ("how one request ended") or its lifecycle
|
|
263
|
+
# status ("every request that was refused"). Each journaled run has such a pair, which a
|
|
264
|
+
# person reads through `hecks <verb> --wait` rather than asking for by name, so the help
|
|
265
|
+
# sets them apart with the bookkeeping verbs. A query that returns a document, or filters
|
|
266
|
+
# on anything else, is a real question. Only an aggregate that journals its own runs (it has
|
|
267
|
+
# system-role commands, the ones `internal` verbs are made of) has such a pair: a release's
|
|
268
|
+
# "every version that was shipped" is a question worth asking by name.
|
|
269
|
+
def bookkeeping_query?(aggregate, query)
|
|
270
|
+
return false if query.returns || query.wheres.empty?
|
|
271
|
+
return false unless aggregate.commands.any? { |command| command.role.to_s == "System" }
|
|
272
|
+
|
|
273
|
+
own = Array(aggregate.identified_by).map(&:to_s) + [aggregate.lifecycle&.field.to_s]
|
|
274
|
+
query.wheres.all? { |clause| own.include?(clause.field.to_s) }
|
|
275
|
+
end
|
|
276
|
+
|
|
260
277
|
def query_spec(bluebook, aggregate, entity, query)
|
|
261
278
|
arguments = Array(query.to_h[:attributes]).flat_map do |declared|
|
|
262
279
|
attribute = query.attributes.find { |a| a.name.to_s == declared[:name].to_s }
|
|
263
280
|
attribute ? options_for(attribute, entity || aggregate, aggregate) : []
|
|
264
281
|
end
|
|
265
282
|
|
|
266
|
-
{ verb: fqn(bluebook, aggregate, query, entity), kind: :query,
|
|
283
|
+
{ verb: fqn(bluebook, aggregate, query, entity), kind: :query, group: aggregate.hecks_name,
|
|
284
|
+
internal: entity.nil? && bookkeeping_query?(aggregate, query),
|
|
267
285
|
summary: query.description, arguments: arguments, returns: query.returns }
|
|
268
286
|
end
|
|
269
287
|
|
|
@@ -373,16 +391,57 @@ module Hecks
|
|
|
373
391
|
" #{program} ask <question> [name=value …] read something", ""]
|
|
374
392
|
|
|
375
393
|
out << "verbs:"
|
|
376
|
-
verbs
|
|
394
|
+
out.concat(listing(verbs, width) { |spec| spec[:summary] })
|
|
377
395
|
out << ""
|
|
378
396
|
out << "questions (nothing here changes anything):"
|
|
379
|
-
questions
|
|
397
|
+
out.concat(listing(questions, width) { |spec| first_sentence(spec[:summary]) })
|
|
380
398
|
out << ""
|
|
381
399
|
out << " #{program} <verb> --help what one verb wants, and every way it refuses"
|
|
382
400
|
out << " a verb can always be spelled in full — #{example_qualified(verbs)}"
|
|
383
401
|
out.join("\n")
|
|
384
402
|
end
|
|
385
403
|
|
|
404
|
+
# The verb or question lines of the help. A domain with more than one aggregate is listed
|
|
405
|
+
# under a heading per aggregate, so related verbs sit together; the bookkeeping a run records
|
|
406
|
+
# about itself (`internal`: system-role commands and port operations) is set apart as names
|
|
407
|
+
# only, since a person never types them. A single-aggregate domain keeps the plain list.
|
|
408
|
+
def listing(specs, width)
|
|
409
|
+
shown, internal = specs.values.partition { |spec| !spec[:internal] }
|
|
410
|
+
groups = shown.group_by { |spec| spec[:group] }
|
|
411
|
+
lines = []
|
|
412
|
+
if groups.length > 1
|
|
413
|
+
groups.each do |group, members|
|
|
414
|
+
lines << " #{heading(group)}" if group
|
|
415
|
+
members.each { |spec| lines << " #{spec[:short].ljust(width)} #{yield(spec)}" }
|
|
416
|
+
end
|
|
417
|
+
else
|
|
418
|
+
shown.each { |spec| lines << " #{spec[:short].ljust(width)} #{yield(spec)}" }
|
|
419
|
+
end
|
|
420
|
+
lines.concat(internal_lines(internal)) unless internal.empty?
|
|
421
|
+
lines
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# "LanguageRun" reads "Language": the Run suffix names the journaled-command shape all of
|
|
425
|
+
# these aggregates share, so it adds nothing under a heading ("Test suite", "Model check").
|
|
426
|
+
def heading(group)
|
|
427
|
+
words = Naming.snake(group.sub(/Run\z/, "")).tr("_", " ")
|
|
428
|
+
"#{words.capitalize}:"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# The internal verbs as bare names, wrapped, under one line saying what they are.
|
|
432
|
+
def internal_lines(specs)
|
|
433
|
+
lines = [" internal — what a run records about itself, named here only (`--help` still works):"]
|
|
434
|
+
line = " "
|
|
435
|
+
specs.map { |spec| spec[:short] }.each do |name|
|
|
436
|
+
if line.length + name.length + 1 > 98
|
|
437
|
+
lines << line
|
|
438
|
+
line = " "
|
|
439
|
+
end
|
|
440
|
+
line += " #{name}"
|
|
441
|
+
end
|
|
442
|
+
lines << line
|
|
443
|
+
end
|
|
444
|
+
|
|
386
445
|
# One verb whose full spelling differs from its short one, or `""` when none does.
|
|
387
446
|
def example_qualified(verbs)
|
|
388
447
|
name, spec = verbs.find { |key, value| key != value[:short] } || verbs.first
|
|
@@ -243,6 +243,8 @@ module Hecks
|
|
|
243
243
|
target_args = ctx.args.merge(
|
|
244
244
|
delegation.source.to_h { |target_key, source_key| [target_key.to_sym, ctx.args[source_key]] }
|
|
245
245
|
)
|
|
246
|
+
# The target is dispatched as if called directly, so its needs and defaults are filled too.
|
|
247
|
+
target_args = enrich_arguments(target_command, target_args)
|
|
246
248
|
|
|
247
249
|
refuse_unknown_arguments(ctx.domain, ctx.aggregate, target_command, target_args,
|
|
248
250
|
extra_identity_heads: entity.identity_heads)
|
|
@@ -29,12 +29,33 @@ module Hecks
|
|
|
29
29
|
# @return [Hash{Symbol => Object}] `args`, with each missing needed fact answered
|
|
30
30
|
# @raise [Runtime::WiringError] when the port that answers a fact is not bound exactly once
|
|
31
31
|
def enrich_arguments(command, args)
|
|
32
|
+
fill_defaults(command, fill_needs(command, args))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Answers each fact the command `needs` that the caller left out.
|
|
36
|
+
def fill_needs(command, args)
|
|
32
37
|
missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
|
|
33
38
|
return args if missing.empty?
|
|
34
39
|
|
|
35
40
|
args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
# Gives each argument the caller left out the default its attribute declares
|
|
44
|
+
# (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
|
|
45
|
+
# not only where a door spells it out. An argument the caller passed is kept, whatever it is.
|
|
46
|
+
#
|
|
47
|
+
# @param command [Class] the command being dispatched
|
|
48
|
+
# @param args [Hash{Symbol => Object}] the arguments the caller passed
|
|
49
|
+
# @return [Hash{Symbol => Object}] `args`, with each absent defaulted argument filled
|
|
50
|
+
def fill_defaults(command, args)
|
|
51
|
+
absent = command.attributes.reject do |attribute|
|
|
52
|
+
attribute.default.nil? || args.key?(attribute.name.to_sym) || args.key?(attribute.name.to_s)
|
|
53
|
+
end
|
|
54
|
+
return args if absent.empty?
|
|
55
|
+
|
|
56
|
+
args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
|
|
57
|
+
end
|
|
58
|
+
|
|
38
59
|
# The answer to one fact, in the shape the command's argument of that name takes: a bare
|
|
39
60
|
# Integer for an Integer attribute, else the one-field value object the language declares
|
|
40
61
|
# for an instant.
|
|
@@ -207,12 +207,14 @@ module Hecks
|
|
|
207
207
|
declared: declared)
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
-
# A fact the command `needs
|
|
211
|
-
# reads the arguments.
|
|
210
|
+
# A fact the command `needs`, and an argument its attribute gives a default, are not absent:
|
|
211
|
+
# the interpreter fills them before any refusal reads the arguments.
|
|
212
212
|
def refuse_absent_facts!(declaring, offered, declared)
|
|
213
213
|
needed = declaring.respond_to?(:needs) ? declaring.needs.map(&:to_sym) : []
|
|
214
|
+
defaulted = declaring.attributes.select { |attribute| attribute.respond_to?(:default) && !attribute.default.nil? }
|
|
215
|
+
.map { |attribute| attribute.name.to_sym }
|
|
214
216
|
absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } -
|
|
215
|
-
offered.keys - needed
|
|
217
|
+
offered.keys - needed - defaulted
|
|
216
218
|
return if absent.empty?
|
|
217
219
|
|
|
218
220
|
raise AbsentArgument,
|
data/lib/hecks/version.rb
CHANGED
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.0.
|
|
1
|
+
3.0.5
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The optional branded confirmation email: an HTML template an operator
|
|
2
|
+
// hosts, fetched at send time. Hecks ships no brand; with no template (or
|
|
3
|
+
// any trouble with one) the caller keeps the plain-text body.
|
|
4
|
+
use super::super::newsletter_send::personalize;
|
|
5
|
+
use std::time::Duration;
|
|
6
|
+
|
|
7
|
+
pub(super) const CONFIRM_TOKEN: &str = "{{CONFIRM_URL}}";
|
|
8
|
+
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
|
9
|
+
pub(super) const MAX_TEMPLATE_BYTES: usize = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
// Escapes a URL for an HTML attribute or text position.
|
|
12
|
+
fn escape_html(value: &str) -> String {
|
|
13
|
+
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Fills the placeholders with HTML-escaped URLs. `Err` names why the
|
|
17
|
+
// template is unusable: without {{CONFIRM_URL}} the email would carry no
|
|
18
|
+
// link. {{UNSUBSCRIBE_URL}} is optional. The result starts at its first
|
|
19
|
+
// non-blank character so the mailer sends it as HTML.
|
|
20
|
+
pub(super) fn fill_template(template: &str, confirm_url: &str, unsubscribe_url: &str) -> Result<String, &'static str> {
|
|
21
|
+
if !template.contains(CONFIRM_TOKEN) {
|
|
22
|
+
return Err("the template has no {{CONFIRM_URL}} placeholder");
|
|
23
|
+
}
|
|
24
|
+
let filled = template.replace(CONFIRM_TOKEN, &escape_html(confirm_url));
|
|
25
|
+
Ok(personalize(&filled, &escape_html(unsubscribe_url)).trim_start().to_string())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reads a response body, stopping as soon as it passes the cap.
|
|
29
|
+
async fn read_capped(mut response: reqwest::Response) -> Result<String, String> {
|
|
30
|
+
let mut bytes: Vec<u8> = Vec::new();
|
|
31
|
+
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
|
32
|
+
bytes.extend_from_slice(&chunk);
|
|
33
|
+
if bytes.len() > MAX_TEMPLATE_BYTES {
|
|
34
|
+
return Err(format!("the template is larger than {MAX_TEMPLATE_BYTES} bytes"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
String::from_utf8(bytes).map_err(|_| "the template is not UTF-8".to_string())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// GETs the template (5 second timeout, size capped); a non-2xx answer or
|
|
41
|
+
// any transport trouble is an `Err` with the reason.
|
|
42
|
+
async fn fetch_template(url: &str) -> Result<String, String> {
|
|
43
|
+
let http = reqwest::Client::builder().timeout(FETCH_TIMEOUT).build().map_err(|e| e.to_string())?;
|
|
44
|
+
let response = http.get(url).send().await.map_err(|e| e.to_string())?;
|
|
45
|
+
if !response.status().is_success() {
|
|
46
|
+
return Err(format!("the template answered {}", response.status()));
|
|
47
|
+
}
|
|
48
|
+
read_capped(response).await
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The HTML body when NEWSLETTER_CONFIRMATION_TEMPLATE_URL names a usable
|
|
52
|
+
// template; None (after logging why) when the caller should send plain text.
|
|
53
|
+
pub(super) async fn branded_body(confirm_url: &str, unsubscribe_url: &str) -> Option<String> {
|
|
54
|
+
let url = std::env::var("NEWSLETTER_CONFIRMATION_TEMPLATE_URL").ok().filter(|u| !u.trim().is_empty())?;
|
|
55
|
+
let template = match fetch_template(url.trim()).await {
|
|
56
|
+
Ok(t) => t,
|
|
57
|
+
Err(e) => {
|
|
58
|
+
eprintln!("newsletter: the confirmation template could not be fetched ({e}), so the plain-text confirmation was sent");
|
|
59
|
+
return None;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
match fill_template(&template, confirm_url, unsubscribe_url) {
|
|
63
|
+
Ok(body) => Some(body),
|
|
64
|
+
Err(e) => {
|
|
65
|
+
eprintln!("newsletter: the confirmation template was rejected ({e}), so the plain-text confirmation was sent");
|
|
66
|
+
None
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -102,3 +102,46 @@ fn a_confirm_token_is_not_an_unsubscribe_token_and_the_reverse() {
|
|
|
102
102
|
assert!(!unsubscribe_token_matches("s3cret-value", &confirm, "a@example.com"));
|
|
103
103
|
assert!(!confirm_token_matches("s3cret-value", &unsubscribe, "a@example.com"));
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
use template::fill_template;
|
|
107
|
+
|
|
108
|
+
const CONFIRM: &str = "https://example.com/newsletter-confirmed.html?email=a%40b.c&token=t";
|
|
109
|
+
const UNSUBSCRIBE: &str = "https://example.com/newsletter-unsubscribed.html?email=a%40b.c&token=u";
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn a_template_gets_the_confirm_link_in_place_of_its_placeholder() {
|
|
113
|
+
let body = fill_template("<p><a href=\"{{CONFIRM_URL}}\">Confirm</a></p>", CONFIRM, UNSUBSCRIBE).unwrap();
|
|
114
|
+
assert_eq!(body, "<p><a href=\"https://example.com/newsletter-confirmed.html?email=a%40b.c&token=t\">Confirm</a></p>");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn a_template_without_the_confirm_placeholder_is_rejected() {
|
|
119
|
+
assert!(fill_template("<p>{{UNSUBSCRIBE_URL}}</p>", CONFIRM, UNSUBSCRIBE).is_err());
|
|
120
|
+
assert!(fill_template("", CONFIRM, UNSUBSCRIBE).is_err());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn the_unsubscribe_placeholder_is_optional_and_replaced_wherever_present() {
|
|
125
|
+
assert!(fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", CONFIRM, UNSUBSCRIBE).is_ok());
|
|
126
|
+
let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>{{UNSUBSCRIBE_URL}}|{{UNSUBSCRIBE_URL}}", CONFIRM, UNSUBSCRIBE).unwrap();
|
|
127
|
+
assert!(!body.contains("{{"));
|
|
128
|
+
assert_eq!(body.matches("newsletter-unsubscribed.html").count(), 2);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn ampersands_and_quotes_in_a_url_are_escaped_inside_the_html() {
|
|
133
|
+
let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", "https://e.com/?a=1&b=\"2\"<>'", UNSUBSCRIBE).unwrap();
|
|
134
|
+
assert_eq!(body, "<a href=\"https://e.com/?a=1&b="2"<>'\">x</a>");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[test]
|
|
138
|
+
fn a_filled_template_starts_at_its_first_tag_so_the_mailer_sends_html() {
|
|
139
|
+
let body = fill_template("\n <p>{{CONFIRM_URL}}</p>", CONFIRM, UNSUBSCRIBE).unwrap();
|
|
140
|
+
assert!(body.starts_with('<'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn without_a_template_the_plain_text_confirmation_is_unchanged() {
|
|
145
|
+
let body = confirmation_body(CONFIRM);
|
|
146
|
+
assert_eq!(body, format!("Please confirm your newsletter subscription by opening this link:\n\n{CONFIRM}\n\nIf you didn't ask for this, you can ignore this email and nothing will be sent to you.\n"));
|
|
147
|
+
}
|
|
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
|
|
|
14
14
|
use tokio_postgres::Client;
|
|
15
15
|
|
|
16
16
|
mod cooldown;
|
|
17
|
+
mod template;
|
|
17
18
|
#[cfg(test)]
|
|
18
19
|
mod tests;
|
|
19
20
|
|
|
@@ -194,8 +195,12 @@ pub(super) async fn send_confirmation(email: &str) {
|
|
|
194
195
|
let site = site_url();
|
|
195
196
|
let link = confirm_url(&site, email, &confirm_token(&secret, email));
|
|
196
197
|
let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(&secret, email));
|
|
198
|
+
let body = match template::branded_body(&link, &unsubscribe).await {
|
|
199
|
+
Some(html) => html,
|
|
200
|
+
None => confirmation_body(&link),
|
|
201
|
+
};
|
|
197
202
|
let delivery = mailer
|
|
198
|
-
.deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &
|
|
203
|
+
.deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &body, unsubscribe_url: Some(&unsubscribe) })
|
|
199
204
|
.await;
|
|
200
205
|
if !delivery.ok {
|
|
201
206
|
eprintln!("newsletter: the confirmation email was not delivered: {}", delivery.reason.unwrap_or_default());
|
|
@@ -132,7 +132,7 @@ pub(super) fn unsubscribe_url(site_url: &str, email: &str, token: &str) -> Strin
|
|
|
132
132
|
url.to_string()
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
fn personalize(body: &str, unsubscribe_url: &str) -> String {
|
|
135
|
+
pub(super) fn personalize(body: &str, unsubscribe_url: &str) -> String {
|
|
136
136
|
body.replace(UNSUBSCRIBE_TOKEN, unsubscribe_url)
|
|
137
137
|
}
|
|
138
138
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.
|
|
4
|
+
version: 3.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -858,6 +858,7 @@ files:
|
|
|
858
858
|
- rust/host/src/web.rs
|
|
859
859
|
- rust/host/src/web/newsletter.rs
|
|
860
860
|
- rust/host/src/web/newsletter/cooldown.rs
|
|
861
|
+
- rust/host/src/web/newsletter/template.rs
|
|
861
862
|
- rust/host/src/web/newsletter/tests.rs
|
|
862
863
|
- rust/host/src/web/newsletter_send.rs
|
|
863
864
|
- rust/host/src/web/registrations.rs
|