hecks 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +86 -31
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +179 -0
  12. data/lib/hecks/doors/cli_runner.rb +14 -1
  13. data/lib/hecks/doors/launcher_options.rb +13 -3
  14. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  15. data/lib/hecks/doors.rb +1 -1
  16. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  17. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  18. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  20. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  21. data/lib/hecks/gate/stages.yml +113 -8
  22. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  23. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  24. data/lib/hecks/hecks/adapters/git.rb +59 -0
  25. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  26. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  27. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  28. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  29. data/lib/hecks/hecks/codebase.behaviors +6 -0
  30. data/lib/hecks/hecks/codebase.bluebook +21 -0
  31. data/lib/hecks/hecks/custodian.behaviors +6 -0
  32. data/lib/hecks/hecks/custodian.bluebook +217 -0
  33. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  34. data/lib/hecks/hecks/hecks.world +8 -3
  35. data/lib/hecks/hecks/site.world +5 -2
  36. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  37. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  38. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  39. data/lib/hecks/language/oidc.json +5 -0
  40. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  41. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  42. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  43. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  44. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  45. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  47. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  48. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  49. data/lib/hecks/projections/deploy/box.rb +15 -2
  50. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  51. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  52. data/lib/hecks/projections/model/deviations.rb +1 -1
  53. data/lib/hecks/projections/site/live_edge.rb +162 -0
  54. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  55. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  56. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  57. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  58. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  59. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  60. data/lib/hecks/runtime/interpreting.rb +3 -22
  61. data/lib/hecks/runtime/needs.rb +48 -0
  62. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  63. data/lib/hecks/runtime/registry/verification.rb +3 -3
  64. data/lib/hecks/rust_build/host.rb +147 -0
  65. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  66. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  67. data/lib/hecks/rust_build/project_rust.rb +3 -10
  68. data/lib/hecks/rust_build.rb +1 -0
  69. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  70. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  71. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  72. data/lib/hecks/tools/gate.rb +1 -1
  73. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  74. data/lib/hecks/tools/tools_doc.rb +204 -0
  75. data/lib/hecks/tools.rb +1 -0
  76. data/lib/hecks/version.rb +1 -1
  77. data/lib/hecks/vocabulary.rb +151 -0
  78. data/qa/settings.yml +1 -2
  79. data/rust/build/src/lineage_pass.rs +30 -15
  80. data/rust/build/src/main.rs +4 -2
  81. data/rust/build/src/pipeline.rs +8 -19
  82. data/rust/codegen/src/bridging.rs +7 -7
  83. data/rust/codegen/src/json.rs +214 -23
  84. data/rust/codegen/src/literal.rs +6 -3
  85. data/rust/codegen/src/main.rs +20 -9
  86. data/rust/codegen/src/naming.rs +4 -2
  87. data/rust/codegen/src/optional_pass.rs +226 -0
  88. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  89. data/rust/host/HECKS_RELEASE +1 -1
  90. data/rust/host/src/api.rs +1 -1
  91. data/rust/host/src/dispatch.rs +5 -0
  92. data/rust/host/src/needs.rs +99 -2
  93. data/rust/host/src/resend.rs +4 -4
  94. data/rust/host/src/ui_schema.rs +1 -1
  95. data/rust/parser/src/emit.rs +12 -0
  96. data/rust/parser/src/ir.rs +2 -0
  97. data/rust/parser/src/keywords.rs +2 -0
  98. data/rust/parser/src/main.rs +1 -0
  99. data/rust/parser/src/parse/command.rs +2 -44
  100. data/rust/parser/src/parse/mod.rs +1 -0
  101. data/rust/parser/src/parse/needs.rs +60 -0
  102. data/rust/parser/src/parse/query.rs +3 -0
  103. data/rust/src/kernel/cli.rs +16 -6
  104. data/rust/src/kernel/needs.rs +104 -9
  105. metadata +22 -8
  106. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  107. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  108. data/lib/hecks/three_zero/forms.yml +0 -127
  109. data/lib/hecks/three_zero.rb +0 -12
  110. data/rust/build/src/json.rs +0 -421
  111. data/rust/build/src/optional_pass.rs +0 -150
@@ -1,421 +0,0 @@
1
- //! A minimal, dependency-free, mutable JSON value with a reader and a pretty writer.
2
- //! The writer matches `JSON.pretty_generate` byte-for-byte, including its empty-array quirk.
3
-
4
- use std::fmt::Write as _;
5
-
6
- #[derive(Debug, Clone, PartialEq)]
7
- pub enum Json {
8
- Null,
9
- Bool(bool),
10
- /// Raw literal text as scanned, so numbers round-trip unchanged.
11
- Number(String),
12
- String(String),
13
- Array(Vec<Json>),
14
- /// Insertion-ordered pairs; byte-exact re-emission depends on key order.
15
- Object(Vec<(String, Json)>),
16
- }
17
-
18
- impl Json {
19
- pub fn parse(text: &str) -> Result<Json, String> {
20
- let mut p = Parser { bytes: text.as_bytes(), pos: 0 };
21
- p.skip_ws();
22
- let value = p.parse_value()?;
23
- p.skip_ws();
24
- if p.pos != p.bytes.len() {
25
- return Err(format!("trailing content at byte {}", p.pos));
26
- }
27
- Ok(value)
28
- }
29
-
30
- /// Looks up `key`, treating a `null` value as missing.
31
- pub fn get(&self, key: &str) -> Option<&Json> {
32
- match self {
33
- Json::Object(pairs) => pairs.iter().find(|(k, v)| k == key && !matches!(v, Json::Null)).map(|(_, v)| v),
34
- _ => None,
35
- }
36
- }
37
-
38
- pub fn get_mut(&mut self, key: &str) -> Option<&mut Json> {
39
- match self {
40
- Json::Object(pairs) => pairs.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
41
- _ => None,
42
- }
43
- }
44
-
45
- pub fn as_str(&self) -> Option<&str> {
46
- match self {
47
- Json::String(s) => Some(s.as_str()),
48
- _ => None,
49
- }
50
- }
51
-
52
- pub fn as_array(&self) -> Option<&[Json]> {
53
- match self {
54
- Json::Array(items) => Some(items.as_slice()),
55
- _ => None,
56
- }
57
- }
58
-
59
- pub fn as_array_mut(&mut self) -> Option<&mut Vec<Json>> {
60
- match self {
61
- Json::Array(items) => Some(items),
62
- _ => None,
63
- }
64
- }
65
-
66
- pub fn as_object(&self) -> Option<&[(String, Json)]> {
67
- match self {
68
- Json::Object(pairs) => Some(pairs.as_slice()),
69
- _ => None,
70
- }
71
- }
72
-
73
- /// Ruby truthiness: everything except `false` and `null` is true.
74
- pub fn as_bool(&self) -> bool {
75
- !matches!(self, Json::Bool(false) | Json::Null)
76
- }
77
-
78
- /// Iterates an array field; empty for any other value.
79
- pub fn each(&self) -> &[Json] {
80
- self.as_array().unwrap_or(&[])
81
- }
82
-
83
- /// Sets `key` to `value`, the only mutation the optional-marking pass performs.
84
- /// Overwrites the existing pair, or appends one if absent.
85
- pub fn set_bool(&mut self, key: &str, value: bool) {
86
- if let Json::Object(pairs) = self {
87
- if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
88
- *existing = Json::Bool(value);
89
- return;
90
- }
91
- pairs.push((key.to_string(), Json::Bool(value)));
92
- }
93
- }
94
-
95
- /// Sets `key` to `value`, overwriting in place or else appending last.
96
- pub fn set(&mut self, key: &str, value: Json) {
97
- if let Json::Object(pairs) = self {
98
- if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
99
- *existing = value;
100
- return;
101
- }
102
- pairs.push((key.to_string(), value));
103
- }
104
- }
105
- }
106
-
107
- struct Parser<'a> {
108
- bytes: &'a [u8],
109
- pos: usize,
110
- }
111
-
112
- impl<'a> Parser<'a> {
113
- fn peek(&self) -> Option<u8> {
114
- self.bytes.get(self.pos).copied()
115
- }
116
-
117
- fn skip_ws(&mut self) {
118
- while let Some(b) = self.peek() {
119
- if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
120
- self.pos += 1;
121
- } else {
122
- break;
123
- }
124
- }
125
- }
126
-
127
- fn expect(&mut self, b: u8) -> Result<(), String> {
128
- if self.peek() == Some(b) {
129
- self.pos += 1;
130
- Ok(())
131
- } else {
132
- Err(format!("expected {:?} at byte {}, found {:?}", b as char, self.pos, self.peek().map(|c| c as char)))
133
- }
134
- }
135
-
136
- fn parse_value(&mut self) -> Result<Json, String> {
137
- self.skip_ws();
138
- match self.peek() {
139
- Some(b'{') => self.parse_object(),
140
- Some(b'[') => self.parse_array(),
141
- Some(b'"') => Ok(Json::String(self.parse_string()?)),
142
- Some(b't') => {
143
- self.expect_literal("true")?;
144
- Ok(Json::Bool(true))
145
- }
146
- Some(b'f') => {
147
- self.expect_literal("false")?;
148
- Ok(Json::Bool(false))
149
- }
150
- Some(b'n') => {
151
- self.expect_literal("null")?;
152
- Ok(Json::Null)
153
- }
154
- Some(c) if c == b'-' || c.is_ascii_digit() => self.parse_number(),
155
- other => Err(format!("unexpected byte {:?} at {}", other.map(|c| c as char), self.pos)),
156
- }
157
- }
158
-
159
- fn expect_literal(&mut self, lit: &str) -> Result<(), String> {
160
- let end = self.pos + lit.len();
161
- if end <= self.bytes.len() && &self.bytes[self.pos..end] == lit.as_bytes() {
162
- self.pos = end;
163
- Ok(())
164
- } else {
165
- Err(format!("expected literal {lit:?} at byte {}", self.pos))
166
- }
167
- }
168
-
169
- fn parse_object(&mut self) -> Result<Json, String> {
170
- self.expect(b'{')?;
171
- let mut pairs = Vec::new();
172
- self.skip_ws();
173
- if self.peek() == Some(b'}') {
174
- self.pos += 1;
175
- return Ok(Json::Object(pairs));
176
- }
177
- loop {
178
- self.skip_ws();
179
- let key = self.parse_string()?;
180
- self.skip_ws();
181
- self.expect(b':')?;
182
- let value = self.parse_value()?;
183
- pairs.push((key, value));
184
- self.skip_ws();
185
- match self.peek() {
186
- Some(b',') => {
187
- self.pos += 1;
188
- }
189
- Some(b'}') => {
190
- self.pos += 1;
191
- break;
192
- }
193
- other => return Err(format!("expected ',' or '}}' at byte {}, found {:?}", self.pos, other.map(|c| c as char))),
194
- }
195
- }
196
- Ok(Json::Object(pairs))
197
- }
198
-
199
- fn parse_array(&mut self) -> Result<Json, String> {
200
- self.expect(b'[')?;
201
- let mut items = Vec::new();
202
- self.skip_ws();
203
- if self.peek() == Some(b']') {
204
- self.pos += 1;
205
- return Ok(Json::Array(items));
206
- }
207
- loop {
208
- let value = self.parse_value()?;
209
- items.push(value);
210
- self.skip_ws();
211
- match self.peek() {
212
- Some(b',') => {
213
- self.pos += 1;
214
- }
215
- Some(b']') => {
216
- self.pos += 1;
217
- break;
218
- }
219
- other => return Err(format!("expected ',' or ']' at byte {}, found {:?}", self.pos, other.map(|c| c as char))),
220
- }
221
- }
222
- Ok(Json::Array(items))
223
- }
224
-
225
- fn parse_string(&mut self) -> Result<String, String> {
226
- self.expect(b'"')?;
227
- let mut out = String::new();
228
- loop {
229
- match self.peek() {
230
- None => return Err("unterminated string".to_string()),
231
- Some(b'"') => {
232
- self.pos += 1;
233
- break;
234
- }
235
- Some(b'\\') => {
236
- self.pos += 1;
237
- match self.peek() {
238
- Some(b'"') => {
239
- out.push('"');
240
- self.pos += 1;
241
- }
242
- Some(b'\\') => {
243
- out.push('\\');
244
- self.pos += 1;
245
- }
246
- Some(b'/') => {
247
- out.push('/');
248
- self.pos += 1;
249
- }
250
- Some(b'n') => {
251
- out.push('\n');
252
- self.pos += 1;
253
- }
254
- Some(b't') => {
255
- out.push('\t');
256
- self.pos += 1;
257
- }
258
- Some(b'r') => {
259
- out.push('\r');
260
- self.pos += 1;
261
- }
262
- Some(b'b') => {
263
- out.push('\u{8}');
264
- self.pos += 1;
265
- }
266
- Some(b'f') => {
267
- out.push('\u{c}');
268
- self.pos += 1;
269
- }
270
- Some(b'u') => {
271
- self.pos += 1;
272
- let hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4]).map_err(|e| e.to_string())?;
273
- let code = u32::from_str_radix(hex, 16).map_err(|e| e.to_string())?;
274
- self.pos += 4;
275
- if let Some(c) = char::from_u32(code) {
276
- out.push(c);
277
- }
278
- }
279
- other => return Err(format!("bad escape {:?}", other.map(|c| c as char))),
280
- }
281
- }
282
- Some(_) => {
283
- // One UTF-8 char at a time so multi-byte text survives.
284
- let start = self.pos;
285
- let rest = std::str::from_utf8(&self.bytes[start..]).map_err(|e| e.to_string())?;
286
- let ch = rest.chars().next().ok_or("empty remainder")?;
287
- out.push(ch);
288
- self.pos += ch.len_utf8();
289
- }
290
- }
291
- }
292
- Ok(out)
293
- }
294
-
295
- // Keeps the raw literal text, so numbers round-trip unchanged.
296
- fn parse_number(&mut self) -> Result<Json, String> {
297
- let start = self.pos;
298
- if self.peek() == Some(b'-') {
299
- self.pos += 1;
300
- }
301
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
302
- self.pos += 1;
303
- }
304
- if self.peek() == Some(b'.') {
305
- self.pos += 1;
306
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
307
- self.pos += 1;
308
- }
309
- }
310
- if matches!(self.peek(), Some(b'e') | Some(b'E')) {
311
- self.pos += 1;
312
- if matches!(self.peek(), Some(b'+') | Some(b'-')) {
313
- self.pos += 1;
314
- }
315
- while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
316
- self.pos += 1;
317
- }
318
- }
319
- let text = std::str::from_utf8(&self.bytes[start..self.pos]).map_err(|e| e.to_string())?;
320
- Ok(Json::Number(text.to_string()))
321
- }
322
- }
323
-
324
- /// Renders `value` exactly as `JSON.pretty_generate` does for the pinned json gem.
325
- pub fn write(value: &Json) -> String {
326
- let mut out = String::new();
327
- write_value(&mut out, value, 0);
328
- out
329
- }
330
-
331
- fn indent(out: &mut String, depth: usize) {
332
- for _ in 0..depth {
333
- out.push_str(" ");
334
- }
335
- }
336
-
337
- fn write_value(out: &mut String, value: &Json, depth: usize) {
338
- match value {
339
- Json::Null => out.push_str("null"),
340
- Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
341
- Json::Number(n) => out.push_str(n),
342
- Json::String(s) => write_string(out, s),
343
- Json::Array(items) => write_array(out, items, depth),
344
- Json::Object(pairs) => write_object(out, pairs, depth),
345
- }
346
- }
347
-
348
- fn write_string(out: &mut String, text: &str) {
349
- out.push('"');
350
- for ch in text.chars() {
351
- match ch {
352
- '"' => out.push_str("\\\""),
353
- '\\' => out.push_str("\\\\"),
354
- '\n' => out.push_str("\\n"),
355
- '\t' => out.push_str("\\t"),
356
- '\r' => out.push_str("\\r"),
357
- c if (c as u32) < 0x20 => {
358
- let _ = write!(out, "\\u{:04x}", c as u32);
359
- }
360
- c => out.push(c),
361
- }
362
- }
363
- out.push('"');
364
- }
365
-
366
- fn write_array(out: &mut String, items: &[Json], depth: usize) {
367
- if items.is_empty() {
368
- // Empty arrays render with a blank line, matching the pinned json gem.
369
- out.push_str("[\n\n");
370
- indent(out, depth);
371
- out.push(']');
372
- return;
373
- }
374
- out.push_str("[\n");
375
- for (idx, item) in items.iter().enumerate() {
376
- indent(out, depth + 1);
377
- write_value(out, item, depth + 1);
378
- if idx + 1 < items.len() {
379
- out.push(',');
380
- }
381
- out.push('\n');
382
- }
383
- indent(out, depth);
384
- out.push(']');
385
- }
386
-
387
- fn write_object(out: &mut String, pairs: &[(String, Json)], depth: usize) {
388
- // An empty object needs no special case: the loop runs zero times.
389
- out.push_str("{\n");
390
- for (idx, (key, val)) in pairs.iter().enumerate() {
391
- indent(out, depth + 1);
392
- write_string(out, key);
393
- out.push_str(": ");
394
- write_value(out, val, depth + 1);
395
- if idx + 1 < pairs.len() {
396
- out.push(',');
397
- }
398
- out.push('\n');
399
- }
400
- indent(out, depth);
401
- out.push('}');
402
- }
403
-
404
- #[cfg(test)]
405
- mod tests {
406
- use super::*;
407
-
408
- #[test]
409
- fn round_trips_an_empty_object() {
410
- let text = "{\n}";
411
- let value = Json::parse(text).expect("parses");
412
- assert_eq!(write(&value), text);
413
- }
414
-
415
- #[test]
416
- fn set_bool_overwrites_an_existing_key() {
417
- let mut value = Json::parse("{\n \"optional\": false\n}").expect("parses");
418
- value.set_bool("optional", true);
419
- assert_eq!(write(&value), "{\n \"optional\": true\n}");
420
- }
421
- }
@@ -1,150 +0,0 @@
1
- //! Marks value-object and entity fields `optional` when an `append` mutation binds them to an
2
- //! optional command argument, so the generated struct field is an `Option`.
3
-
4
- use crate::json::Json;
5
-
6
- pub fn run(ir: &mut Json) {
7
- if let Some(aggregates) = ir.get_mut("aggregates").and_then(Json::as_array_mut) {
8
- for aggregate in aggregates.iter_mut() {
9
- mark_append_optional_fields(aggregate);
10
- }
11
- }
12
- }
13
-
14
- enum ElementRef {
15
- ValueObject(usize),
16
- Entity(usize),
17
- }
18
-
19
- fn find_element_ref(aggregate: &Json, target_type: &str) -> Option<ElementRef> {
20
- if let Some(idx) = aggregate.get("value_objects").map(Json::each).unwrap_or(&[]).iter().position(|vo| vo.get("name").and_then(Json::as_str) == Some(target_type)) {
21
- return Some(ElementRef::ValueObject(idx));
22
- }
23
- if let Some(idx) = aggregate.get("entities").map(Json::each).unwrap_or(&[]).iter().position(|e| e.get("name").and_then(Json::as_str) == Some(target_type)) {
24
- return Some(ElementRef::Entity(idx));
25
- }
26
- None
27
- }
28
-
29
- // Gathers the fields to mark first, then applies them: the commands are read while the
30
- // value objects and entities are mutated.
31
- fn mark_append_optional_fields(aggregate: &mut Json) {
32
- let target_attrs: Vec<(String, String)> = aggregate
33
- .get("attributes")
34
- .map(Json::each)
35
- .unwrap_or(&[])
36
- .iter()
37
- .filter_map(|a| {
38
- let name = a.get("name").and_then(Json::as_str)?;
39
- let type_name = a.get("type").and_then(Json::as_str)?;
40
- Some((name.to_string(), type_name.to_string()))
41
- })
42
- .collect();
43
-
44
- for (attr_name, attr_type) in target_attrs {
45
- let Some(element_ref) = find_element_ref(aggregate, &attr_type) else { continue };
46
-
47
- let fields_to_mark = collect_fields_to_mark(aggregate, &attr_name);
48
- if fields_to_mark.is_empty() {
49
- continue;
50
- }
51
-
52
- apply_marks(aggregate, &element_ref, &fields_to_mark);
53
- }
54
- }
55
-
56
- fn collect_fields_to_mark(aggregate: &Json, target_attr_name: &str) -> Vec<String> {
57
- let mut out = Vec::new();
58
-
59
- for command in aggregate.get("commands").map(Json::each).unwrap_or(&[]) {
60
- let command_attrs = command.get("attributes").map(Json::each).unwrap_or(&[]);
61
-
62
- for mutation in command.get("mutations").map(Json::each).unwrap_or(&[]) {
63
- let op = mutation.get("op").and_then(Json::as_str).unwrap_or("");
64
- let target = mutation.get("target").and_then(Json::as_str).unwrap_or("");
65
- if op != "append" || target != target_attr_name {
66
- continue;
67
- }
68
-
69
- let Some(fields) = mutation.get("fields").and_then(Json::as_object) else { continue };
70
- for (field_name, source) in fields {
71
- // Only a Symbol (an argument name) renders with a leading `:` in `Literal.render`.
72
- let Some(source_text) = source.as_str() else { continue };
73
- let Some(arg_name) = source_text.strip_prefix(':') else { continue };
74
-
75
- let source_is_optional = command_attrs.iter().any(|a| {
76
- a.get("name").and_then(Json::as_str) == Some(arg_name) && a.get("optional").map(Json::as_bool).unwrap_or(false)
77
- });
78
- if source_is_optional {
79
- out.push(field_name.clone());
80
- }
81
- }
82
- }
83
- }
84
-
85
- out
86
- }
87
-
88
- fn apply_marks(aggregate: &mut Json, element_ref: &ElementRef, fields_to_mark: &[String]) {
89
- let element = match element_ref {
90
- ElementRef::ValueObject(idx) => aggregate.get_mut("value_objects").and_then(Json::as_array_mut).and_then(|v| v.get_mut(*idx)),
91
- ElementRef::Entity(idx) => aggregate.get_mut("entities").and_then(Json::as_array_mut).and_then(|v| v.get_mut(*idx)),
92
- };
93
- let Some(element) = element else { return };
94
- let Some(attrs) = element.get_mut("attributes").and_then(Json::as_array_mut) else { return };
95
-
96
- for field_name in fields_to_mark {
97
- if let Some(field_attr) = attrs.iter_mut().find(|a| a.get("name").and_then(Json::as_str) == Some(field_name.as_str())) {
98
- field_attr.set_bool("optional", true);
99
- }
100
- }
101
- }
102
-
103
- #[cfg(test)]
104
- mod tests {
105
- use super::*;
106
-
107
- // Banking's `Account.Credit` shape: entity list `ledger`, one `append` binding `amount` from
108
- // an optional argument and `direction` from a literal Hash.
109
- fn fixture() -> Json {
110
- Json::parse(
111
- r#"{
112
- "attributes": [
113
- {"name": "ledger", "type": "LedgerEntry", "list": true, "default": null, "optional": false, "pattern": null, "admits": null}
114
- ],
115
- "value_objects": [],
116
- "entities": [
117
- {
118
- "name": "LedgerEntry",
119
- "attributes": [
120
- {"name": "amount", "type": "Money", "list": false, "default": null, "optional": false, "pattern": null, "admits": null},
121
- {"name": "direction", "type": "Direction", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}
122
- ]
123
- }
124
- ],
125
- "commands": [
126
- {
127
- "name": "Credit",
128
- "attributes": [
129
- {"name": "amount", "type": "PositiveMoney", "list": false, "default": null, "optional": true, "pattern": null, "admits": null}
130
- ],
131
- "mutations": [
132
- {"target": "ledger", "op": "append", "fields": {"amount": ":amount", "direction": "{value: \"credit\"}"}}
133
- ]
134
- }
135
- ]
136
- }"#,
137
- )
138
- .expect("fixture parses")
139
- }
140
-
141
- #[test]
142
- fn marks_the_argument_sourced_field_optional() {
143
- let mut aggregate = fixture();
144
- mark_append_optional_fields(&mut aggregate);
145
-
146
- let entity = &aggregate.get("entities").unwrap().each()[0];
147
- let amount = entity.get("attributes").unwrap().each().iter().find(|a| a.get("name").and_then(Json::as_str) == Some("amount")).unwrap();
148
- assert!(amount.get("optional").unwrap().as_bool());
149
- }
150
- }