1use areev_cal::classify::{classify, StatementClass};
27use areev_cal::{parse, CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
28use areev_core::error::{AreevError, Hash};
29use areev_core::format::deserialize::DeserializedGrain;
30use areev_core::authz::Verb;
31use areev_core::types::{Fact, Grain, GrainType};
32use areev_store::{Areev, TelemetryMode, OP_FORGET};
33use serde_json::{Map, Value};
34use std::collections::BTreeSet;
35use areev_loop::error::{Error as WErr, Result as WResult};
36use areev_loop::{
37 BudgetUsage, Capabilities, GrainAccess, GrainRecord, GrainSpec, HeadGroup, OmsSubstrate,
38 QueryUsage, ReadOpts, SubstrateRead, TelemetryView,
39};
40
41const LOOP_NS: &str = "areev-loop";
43const STATE_SUBJECT: &str = "__loop_state__";
45const STATE_RELATION: &str = "state";
46const MAX_SCAN: usize = 1_000_000;
48
49fn we(e: AreevError) -> WErr {
50 WErr::Substrate(e.to_string())
51}
52
53fn require_read(f: &AreevFacade, ns: &str) -> WResult<()> {
54 f.authz().check(Verb::Read, ns).map_err(we)
55}
56
57fn may_read(f: &AreevFacade, ns: &str) -> bool {
58 f.authz().allows(Verb::Read, ns)
59}
60
61pub struct AreevSubstrate {
63 facade: AreevFacade,
64}
65
66impl AreevSubstrate {
67 pub fn new(store: Areev, session_ns: Option<String>) -> Self {
70 let ns = session_ns.unwrap_or_else(|| LOOP_NS.to_string());
71 AreevSubstrate {
72 facade: AreevFacade::with_session(store, Some(ns), None),
73 }
74 }
75
76 pub fn into_store(self) -> Areev {
78 self.facade.into_inner()
79 }
80}
81
82pub struct BorrowedSubstrate<'a> {
85 facade: &'a AreevFacade,
86}
87
88impl<'a> BorrowedSubstrate<'a> {
89 pub fn new(facade: &'a AreevFacade) -> Self {
90 BorrowedSubstrate { facade }
91 }
92}
93
94impl AreevSubstrate {
95 fn facade_ref(&self) -> &AreevFacade {
96 &self.facade
97 }
98
99 pub fn facade(&self) -> &AreevFacade {
102 &self.facade
103 }
104}
105
106impl BorrowedSubstrate<'_> {
107 fn facade_ref(&self) -> &AreevFacade {
108 self.facade
109 }
110}
111
112macro_rules! impl_substrate {
115 ($ty:ty) => {
116 impl SubstrateRead for $ty {
117 fn capabilities(&self) -> Capabilities {
118 caps(self.facade_ref())
119 }
120 fn grains_of_type(
121 &self,
122 grain_type: &str,
123 namespace: Option<&str>,
124 opts: ReadOpts,
125 ) -> WResult<Vec<GrainRecord>> {
126 grains_of_type(self.facade_ref(), grain_type, namespace, opts)
127 }
128 fn grain(&self, hash: &str) -> WResult<Option<GrainRecord>> {
129 grain(self.facade_ref(), hash)
130 }
131 fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
132 heads(self.facade_ref(), namespace)
133 }
134 fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
135 telemetry(self.facade_ref(), namespace)
136 }
137 }
138
139 impl OmsSubstrate for $ty {
140 fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
141 put_grain(self.facade_ref(), spec)
142 }
143 fn supersede(
144 &mut self,
145 target_hash: &str,
146 spec: &GrainSpec,
147 _j: &str,
148 ) -> WResult<String> {
149 supersede_op(self.facade_ref(), target_hash, spec)
150 }
151 fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
152 retract_op(self.facade_ref(), hash)
153 }
154 fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
155 execute_cal(self.facade_ref(), cal)
156 }
157 fn validate_cal(&self, cal: &str) -> WResult<()> {
158 validate_cal(cal)
159 }
160 fn definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
161 definition_inverse(self.facade_ref(), statement)
162 }
163 fn load_state(&self) -> WResult<Value> {
164 load_state(self.facade_ref())
165 }
166 fn store_state(&mut self, state: &Value) -> WResult<()> {
167 store_state(self.facade_ref(), state)
168 }
169 fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
170 put_blob_op(self.facade_ref(), bytes)
171 }
172 fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
173 get_blob_op(self.facade_ref(), address)
174 }
175 }
176 };
177}
178
179impl_substrate!(AreevSubstrate);
180impl_substrate!(BorrowedSubstrate<'_>);
181
182fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
185 require_read(f, ns)?;
189 f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
190 .map_err(we)
191}
192
193fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
195 let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
196 let mut seen = BTreeSet::new();
197 let mut out = Vec::new();
198 for op in ops {
199 if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
200 continue;
201 }
202 if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
203 if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
204 out.push(g);
205 }
206 }
207 }
208 Ok(out)
209}
210
211fn read_user_type(
212 f: &AreevFacade,
213 gt: GrainType,
214 namespace: Option<&str>,
215 opts: ReadOpts,
216) -> WResult<Vec<GrainRecord>> {
217 let explicit = namespace.is_some();
218 let raw = match namespace {
219 Some(ns) => recent_ns(f, ns, gt)?,
220 None => all_grains(f, gt)?,
221 };
222 let superseded = superseded_set(&raw);
223 let mut out = Vec::new();
224 for g in raw {
225 let ns = grain_namespace(&g);
226 if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
238 continue;
239 }
240 if opts.live_only && superseded.contains(&g.hash.to_hex()) {
241 continue;
242 }
243 let created = grain_created_ms(&g);
244 if opts.since_ms.is_some_and(|s| created < s) {
245 continue;
246 }
247 out.push(map_user_grain(&g, ns, created));
248 }
249 Ok(out)
250}
251
252fn read_loop(
254 f: &AreevFacade,
255 relation: &str,
256 out_type: &str,
257 opts: ReadOpts,
258) -> WResult<Vec<GrainRecord>> {
259 let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
260 let mut out = Vec::new();
261 for fact in facts {
262 if fact.get_str("relation") != Some(relation) {
263 continue;
264 }
265 let Some(payload) = fact.get_str("object") else {
266 continue;
267 };
268 let fields: Map<String, Value> = match serde_json::from_str(payload) {
269 Ok(Value::Object(m)) => m,
270 _ => continue,
271 };
272 let created = grain_created_ms(&fact);
273 if opts.since_ms.is_some_and(|s| created < s) {
274 continue;
275 }
276 out.push(loop_record(
277 &fact.hash.to_hex(),
278 out_type,
279 created,
280 fields,
281 ));
282 }
283 Ok(out)
284}
285
286fn caps(f: &AreevFacade) -> Capabilities {
287 Capabilities {
288 forks: true,
289 telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
291 embeddings: f.with_store(|m| m.declared_embedding().is_some()),
292 }
293}
294
295fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
299 if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
300 return Ok(None);
301 }
302 if let Some(ns) = namespace {
303 require_read(f, ns)?;
304 }
305 let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
306 let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
307 let budget = if may_read(f, "*") {
311 f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
312 } else {
313 Default::default()
314 };
315 Ok(Some(TelemetryView {
316 access: access
317 .into_iter()
318 .filter(|a| may_read(f, &a.ns))
319 .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
320 .collect(),
321 queries: queries
322 .into_iter()
323 .filter(|q| may_read(f, &q.ns))
324 .map(|q| QueryUsage {
325 sample: q.sample,
326 run_count: q.run_count,
327 empty_count: q.empty_count,
328 sum_results: q.sum_results,
329 last_ms: q.last_ms,
330 })
331 .collect(),
332 budget: BudgetUsage {
333 sample_count: budget.sample_count,
334 overflow_count: budget.overflow_count,
335 },
336 }))
337}
338
339fn grains_of_type(
340 f: &AreevFacade,
341 grain_type: &str,
342 namespace: Option<&str>,
343 opts: ReadOpts,
344) -> WResult<Vec<GrainRecord>> {
345 match grain_type {
346 "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
347 "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
348 "event" => read_user_type(f, GrainType::Event, namespace, opts),
349 "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
350 "observation" if namespace == Some(LOOP_NS) => {
354 read_loop(f, "loop_audit", "observation", opts)
355 }
356 "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
357 "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
358 "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
359 other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
360 }
361}
362
363fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
364 let h = Hash::from_hex(hash).map_err(we)?;
365 let g = match f.with_store(|m| m.get(&h)) {
366 Ok(g) => g,
367 Err(AreevError::NotFound(_)) => return Ok(None),
368 Err(e) => return Err(we(e)),
369 };
370 let created = grain_created_ms(&g);
371 let superseded_by = f
377 .with_store(|m| m.supersession_map(&[h]))
378 .map_err(we)?
379 .get(&h)
380 .map(|s| s.to_hex());
381 let ns = grain_namespace(&g);
382 if ns == LOOP_NS {
387 if let Some(rel) = g.get_str("relation") {
388 if let Some(out_type) = loop_relation_type(rel) {
389 require_read(f, LOOP_NS)?;
390 if let Some(Value::Object(fields)) = g
391 .get_str("object")
392 .and_then(|p| serde_json::from_str(p).ok())
393 {
394 let mut rec = loop_record(hash, out_type, created, fields);
395 rec.superseded_by = superseded_by;
396 return Ok(Some(rec));
397 }
398 }
399 }
400 }
401 require_read(f, &ns)?;
402 let mut rec = map_user_grain(&g, ns, created);
403 rec.superseded_by = superseded_by;
404 Ok(Some(rec))
405}
406
407fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
408 let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
409 Ok(forks
410 .into_iter()
411 .filter(|fg| fg.namespace != LOOP_NS)
412 .filter(|fg| may_read(f, &fg.namespace))
413 .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
414 .map(|fg| HeadGroup {
415 entity: format!("{}/{}", fg.subject, fg.relation),
416 heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
417 })
418 .collect())
419}
420
421const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
428
429fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
430 f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
431 if bytes.len() > MAX_LOOP_BLOB {
432 return Err(WErr::Substrate(format!(
433 "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
434 bytes.len()
435 )));
436 }
437 f.with_store(|m| m.put_blob(bytes)).map_err(we)
438}
439
440fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
441 require_read(f, LOOP_NS)?;
442 f.with_store(|m| m.get_blob(address)).map_err(we)
443}
444
445fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
446 if spec.grain_type == "fact" {
452 if let (Some(subject), Some(relation), Some(object)) = (
453 spec.fields.get("subject").and_then(Value::as_str),
454 spec.fields.get("relation").and_then(Value::as_str),
455 spec.fields.get("object").and_then(Value::as_str),
456 ) {
457 let mut fact = Fact::new(subject, relation, object)
458 .namespace(LOOP_NS)
459 .confidence(1.0);
460 fact.common.created_at = spec_created_ms(spec);
461 for (k, v) in &spec.fields {
462 if !matches!(k.as_str(), "subject" | "relation" | "object") {
463 fact.common.extra_fields.insert(k.clone(), v.clone());
464 }
465 }
466 return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
467 }
468 }
469 let payload = serde_json::to_string(&spec.fields)
470 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
471 let mut fact = Fact::new(
472 &unique_subject(&payload),
473 loop_relation(&spec.grain_type),
474 &payload,
475 )
476 .namespace(LOOP_NS)
477 .confidence(1.0);
478 fact.common.created_at = spec_created_ms(spec);
479 f.with_store(|m| m.add(&fact))
480 .map(|h| h.to_hex())
481 .map_err(we)
482}
483
484fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
485 let payload = serde_json::to_string(&spec.fields)
486 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
487 let old = Hash::from_hex(target_hash).map_err(we)?;
488 let mut fact = Fact::new(
489 &unique_subject(&payload),
490 loop_relation(&spec.grain_type),
491 &payload,
492 )
493 .namespace(LOOP_NS);
494 fact.common.created_at = spec_created_ms(spec);
495 f.with_store(|m| m.supersede(&old, &mut fact))
496 .map(|h| h.to_hex())
497 .map_err(we)
498}
499
500fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
507 spec.fields
508 .get("created_at_ms")
509 .or_else(|| spec.fields.get("at_ms"))
510 .and_then(Value::as_i64)
511}
512
513fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
514 let h = Hash::from_hex(hash).map_err(we)?;
517 f.with_store(|m| m.forget(&h)).map_err(we)
518}
519
520fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
521 let mut rows = Vec::new();
526 for line in cal.lines() {
527 let line = line.trim();
528 if line.is_empty() {
529 continue;
530 }
531 let (keyword, rest) = split_keyword(line);
532 match keyword.to_ascii_uppercase().as_str() {
533 "FORGET" => {
534 let h = Hash::from_hex(rest.trim()).map_err(we)?;
535 f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
538 }
539 "ADD" => {
540 let (gtype, fields) = parse_type_and_json(rest)?;
541 let h = f.cal_add(>ype, &fields).map_err(we)?;
542 rows.push(serde_json::json!({ "hash": h.to_hex() }));
543 }
544 "SUPERSEDE" => {
545 let (target, after) = rest
546 .split_once(" WITH ")
547 .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
548 let (gtype, fields) = parse_type_and_json(after)?;
549 let old = Hash::from_hex(target.trim()).map_err(we)?;
550 let h = f.cal_supersede(&old, >ype, &fields).map_err(we)?;
551 rows.push(serde_json::json!({ "hash": h.to_hex() }));
552 }
553 _ => {
554 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
561 if classify(&parsed.statement) == StatementClass::Destructive {
562 return Err(WErr::CalUnsupported(format!(
563 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
564 )));
565 }
566 let ex = CalExecutor::new(CalExecutorConfig::default());
567 let res = ex
568 .execute(line, f)
569 .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
570 let payload = serde_json::to_value(&res.result)
571 .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
572 let mut hs = Vec::new();
573 collect_hashes(&payload, &mut hs);
574 rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
575 }
576 }
577 }
578 Ok(rows)
579}
580
581fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
595 let up = statement.trim_start().to_ascii_uppercase();
596 let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
597 ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
598 } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
599 ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
600 } else {
601 return Ok(None);
602 };
603 let name = after
605 .trim_start()
606 .trim_start_matches('"')
607 .split(['"', '(', ' ', '\t'])
608 .next()
609 .unwrap_or("")
610 .trim()
611 .to_string();
612 if name.is_empty() {
613 return Ok(None);
614 }
615 if kind == "QUERY" {
616 match f.get_query(&name) {
617 Some(e) if e.builtin => Ok(None),
619 Some(e) => {
620 let params = if e.params.is_empty() {
621 String::new()
622 } else {
623 let list: Vec<String> =
624 e.params.iter().map(|p| format!("${}", p.name)).collect();
625 format!("({})", list.join(", "))
626 };
627 Ok(Some(format!(
628 "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
629 e.description.replace('"', "'"),
630 e.body
631 )))
632 }
633 None => Ok(Some(format!("DROP QUERY \"{name}\""))),
634 }
635 } else {
636 match f.get_template(&name) {
637 Some(t) if t.builtin => Ok(None),
638 Some(t) => Ok(Some(format!(
639 "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
640 t.description.replace('"', "'"),
641 t.source
642 ))),
643 None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
644 }
645 }
646}
647
648fn validate_cal(cal: &str) -> WResult<()> {
649 for line in cal.lines() {
650 let line = line.trim();
651 if line.is_empty() {
652 continue;
653 }
654 let (keyword, rest) = split_keyword(line);
655 match keyword.to_ascii_uppercase().as_str() {
656 "ADD" | "SUPERSEDE" => {}
657 "FORGET" => {
660 Hash::from_hex(rest.trim()).map_err(|_| {
661 WErr::CalUnsupported(format!(
662 "loop FORGET takes a single grain hash: {line}"
663 ))
664 })?;
665 }
666 _ => {
667 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
668 if classify(&parsed.statement) == StatementClass::Destructive {
672 return Err(WErr::CalUnsupported(format!(
673 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
674 )));
675 }
676 }
677 }
678 }
679 Ok(())
680}
681
682fn load_state(f: &AreevFacade) -> WResult<Value> {
683 let head = f
684 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
685 .map_err(we)?;
686 match head.as_ref().and_then(|g| g.get_str("object")) {
687 Some(json) => serde_json::from_str(json)
688 .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
689 None => Ok(Value::Null),
690 }
691}
692
693fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
707 let json = serde_json::to_string(state)
708 .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
709 let existing = f
710 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
711 .map_err(we)?;
712 f.with_store(|m| {
713 let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
714 match &existing {
715 Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
716 None => m.add(&fact).map(|_| ()),
717 }
718 })
719 .map_err(we)
720}
721
722fn loop_relation(grain_type: &str) -> &'static str {
725 match grain_type {
726 "recommendation" => "loop_recommendation",
727 _ => "loop_audit", }
729}
730
731fn loop_relation_type(relation: &str) -> Option<&'static str> {
732 match relation {
733 "loop_recommendation" => Some("recommendation"),
734 "loop_audit" => Some("observation"),
735 _ => None,
736 }
737}
738
739fn unique_subject(payload: &str) -> String {
742 use std::hash::{Hash as _, Hasher};
743 let mut h = std::collections::hash_map::DefaultHasher::new();
744 payload.hash(&mut h);
745 format!("w{:016x}", h.finish())
746}
747
748fn grain_namespace(g: &DeserializedGrain) -> String {
749 g.get_str("namespace").unwrap_or("").to_string()
750}
751
752fn grain_created_ms(g: &DeserializedGrain) -> i64 {
753 g.get_i64("created_at")
754 .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
755}
756
757fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
758 GrainRecord {
759 hash: g.hash.to_hex(),
760 grain_type: g.grain_type.as_str().to_string(),
761 namespace,
762 created_at_ms: created_ms,
763 valid_to_ms: g.get_i64("valid_to"),
764 superseded_by: None,
765 fields: g.fields.clone().into_iter().collect(),
766 }
767}
768
769fn loop_record(
770 hash: &str,
771 grain_type: &str,
772 created_ms: i64,
773 fields: Map<String, Value>,
774) -> GrainRecord {
775 let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
776 GrainRecord {
777 hash: hash.to_string(),
778 grain_type: grain_type.to_string(),
779 namespace: LOOP_NS.to_string(),
780 created_at_ms: created_ms,
781 valid_to_ms,
782 superseded_by: None,
783 fields,
784 }
785}
786
787fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
789 let mut set = BTreeSet::new();
790 for g in grains {
791 for parent in derived_parents(g) {
792 set.insert(parent);
793 }
794 }
795 set
796}
797
798fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
799 match g.fields.get("derived_from") {
800 Some(Value::String(s)) => vec![s.clone()],
801 Some(Value::Array(a)) => a
802 .iter()
803 .filter_map(|v| v.as_str().map(str::to_string))
804 .collect(),
805 _ => Vec::new(),
806 }
807}
808
809fn split_keyword(line: &str) -> (&str, &str) {
810 match line.split_once(char::is_whitespace) {
811 Some((k, rest)) => (k, rest.trim_start()),
812 None => (line, ""),
813 }
814}
815
816fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
818 let brace = s
819 .find('{')
820 .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
821 let gtype = s[..brace].trim().to_string();
822 if gtype.is_empty() {
823 return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
824 }
825 let value: Value = serde_json::from_str(s[brace..].trim())
826 .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
827 match value {
828 Value::Object(m) => Ok((gtype, m)),
829 _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
830 }
831}
832
833fn collect_hashes(v: &Value, out: &mut Vec<String>) {
834 match v {
835 Value::Object(m) => {
836 for (k, val) in m {
837 if (k == "hash" || k == "new_hash") && val.is_string() {
838 out.push(val.as_str().unwrap().to_string());
839 } else {
840 collect_hashes(val, out);
841 }
842 }
843 }
844 Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
845 _ => {}
846 }
847}