1use std::collections::HashMap;
7
8use areev_cal::store_types::SearchHit;
9use areev_core::format::deserialize::DeserializedGrain;
10use areev_core::types::GrainType;
11
12use super::policy::{FormatPolicy, MetadataLevel, OutputFormat};
13
14pub trait GrainRenderer: Send + Sync {
18 fn grain_type(&self) -> GrainType;
20
21 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String;
23
24 fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
26 self.render(grain, policy)
27 }
28
29 fn token_estimate(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> usize {
31 let chars: usize = grain
32 .fields
33 .iter()
34 .map(|(k, v)| k.len() + json_display_len(v) + 4)
35 .sum();
36 let meta_overhead = match policy.metadata {
37 MetadataLevel::None => 0,
38 MetadataLevel::Minimal => 30,
39 MetadataLevel::Full => 80,
40 };
41 (chars + meta_overhead) / 4
42 }
43
44 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32;
46}
47
48fn json_display_len(v: &serde_json::Value) -> usize {
50 match v {
51 serde_json::Value::Null => 4,
52 serde_json::Value::Bool(b) => {
53 if *b {
54 4
55 } else {
56 5
57 }
58 }
59 serde_json::Value::Number(n) => n.to_string().len(),
60 serde_json::Value::String(s) => s.len(),
61 serde_json::Value::Array(a) => a.iter().map(json_display_len).sum::<usize>() + a.len() * 2,
62 serde_json::Value::Object(o) => o
63 .iter()
64 .map(|(k, v)| k.len() + json_display_len(v) + 4)
65 .sum(),
66 }
67}
68
69pub struct RendererRegistry {
71 renderers: HashMap<GrainType, Box<dyn GrainRenderer>>,
72}
73
74impl Default for RendererRegistry {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl RendererRegistry {
81 pub fn new() -> Self {
83 let mut registry = Self {
84 renderers: HashMap::with_capacity(10),
85 };
86 registry.register(Box::new(FactRenderer));
87 registry.register(Box::new(EventRenderer));
88 registry.register(Box::new(StateRenderer));
89 registry.register(Box::new(WorkflowRenderer));
90 registry.register(Box::new(ToolRenderer));
91 registry.register(Box::new(ObservationRenderer));
92 registry.register(Box::new(GoalRenderer));
93 registry.register(Box::new(ReasoningRenderer));
94 registry.register(Box::new(ConsensusRenderer));
95 registry.register(Box::new(ConsentRenderer));
96 registry.register(Box::new(SkillRenderer));
97 registry.register(Box::new(RecommendationRenderer));
98 registry
99 }
100
101 pub fn get(&self, gt: GrainType) -> Option<&dyn GrainRenderer> {
103 self.renderers.get(>).map(|r| r.as_ref())
104 }
105
106 pub fn register(&mut self, renderer: Box<dyn GrainRenderer>) {
108 self.renderers.insert(renderer.grain_type(), renderer);
109 }
110}
111
112fn adjusted_priority(base: f32, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
118 let score_boost = hit.score as f32 * 0.15;
119 let confidence = grain.get_f64("confidence").unwrap_or(1.0) as f32;
120 let confidence_boost = (confidence - 0.5).max(0.0) * 0.1;
121 let verification_penalty = match grain.get_str("verification_status") {
122 Some("retracted") => -0.3,
123 Some("contested") => -0.15,
124 _ => 0.0,
125 };
126 (base + score_boost + confidence_boost + verification_penalty).clamp(0.0, 1.0)
127}
128
129struct MetadataFragment {
131 confidence: Option<f64>,
132 created_at_sec: u32,
133 hash_hex: String,
134 tags: Vec<String>,
135 source_type: Option<String>,
136 namespace: Option<String>,
137 verification_status: Option<String>,
138}
139
140fn extract_metadata(grain: &DeserializedGrain, level: MetadataLevel) -> Option<MetadataFragment> {
141 match level {
142 MetadataLevel::None => None,
143 MetadataLevel::Minimal | MetadataLevel::Full => {
144 let confidence = grain.get_f64("confidence");
145 let created_at_sec = grain.header.created_at_sec;
146 let hash_hex = grain.hash.to_hex();
147
148 let (tags, source_type, namespace, verification_status) =
149 if level == MetadataLevel::Full {
150 let tags = grain
151 .fields
152 .get("structural_tags")
153 .or_else(|| grain.fields.get("tags"))
154 .and_then(|v| v.as_array())
155 .map(|arr| {
156 arr.iter()
157 .filter_map(|v| v.as_str().map(String::from))
158 .collect()
159 })
160 .unwrap_or_default();
161 (
162 tags,
163 grain.get_str("source_type").map(String::from),
164 grain.get_str("namespace").map(String::from),
165 grain.get_str("verification_status").map(String::from),
166 )
167 } else {
168 (Vec::new(), None, None, None)
169 };
170
171 Some(MetadataFragment {
172 confidence,
173 created_at_sec,
174 hash_hex,
175 tags,
176 source_type,
177 namespace,
178 verification_status,
179 })
180 }
181 }
182}
183
184fn format_date(epoch_sec: u32) -> String {
186 let secs = epoch_sec as i64;
187 let days = secs / 86400;
189 let (y, m, d) = days_to_ymd(days);
191 format!("{y:04}-{m:02}-{d:02}")
192}
193
194fn days_to_ymd(mut days: i64) -> (i64, u32, u32) {
196 days += 719468;
198 let era = if days >= 0 { days } else { days - 146096 } / 146097;
199 let doe = (days - era * 146097) as u32;
200 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
201 let y = yoe as i64 + era * 400;
202 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
203 let mp = (5 * doy + 2) / 153;
204 let d = doy - (153 * mp + 2) / 5 + 1;
205 let m = if mp < 10 { mp + 3 } else { mp - 9 };
206 let y = if m <= 2 { y + 1 } else { y };
207 (y, m, d)
208}
209
210fn format_meta_sml_attrs(meta: &MetadataFragment, level: MetadataLevel) -> String {
211 let mut attrs = Vec::new();
212 if let Some(c) = meta.confidence {
213 attrs.push(format!("confidence=\"{c:.2}\""));
214 }
215 if meta.created_at_sec > 0 {
216 attrs.push(format!("date=\"{}\"", format_date(meta.created_at_sec)));
217 }
218 if level == MetadataLevel::Full {
219 attrs.push(format!("hash=\"{}\"", &meta.hash_hex[..16]));
220 if let Some(ref ns) = meta.namespace {
221 attrs.push(format!("namespace=\"{}\"", sml_escape(ns)));
222 }
223 if let Some(ref vs) = meta.verification_status {
224 attrs.push(format!("status=\"{}\"", sml_escape(vs)));
225 }
226 if !meta.tags.is_empty() {
227 attrs.push(format!("tags=\"{}\"", sml_escape(&meta.tags.join(","))));
228 }
229 }
230 if attrs.is_empty() {
231 String::new()
232 } else {
233 format!(" {}", attrs.join(" "))
234 }
235}
236
237fn format_meta_markdown(meta: &MetadataFragment, level: MetadataLevel) -> String {
238 let mut parts = Vec::new();
239 if let Some(c) = meta.confidence {
240 parts.push(format!("{c:.2}"));
241 }
242 if meta.created_at_sec > 0 {
243 parts.push(format_date(meta.created_at_sec));
244 }
245 if level == MetadataLevel::Full {
246 if let Some(ref vs) = meta.verification_status {
247 parts.push(vs.clone());
248 }
249 }
250 if parts.is_empty() {
251 String::new()
252 } else {
253 format!(" *({})*", parts.join(", "))
254 }
255}
256
257fn format_meta_plain(meta: &MetadataFragment, level: MetadataLevel) -> String {
258 let mut parts = Vec::new();
259 if let Some(c) = meta.confidence {
260 parts.push(format!("{c:.2}"));
261 }
262 if meta.created_at_sec > 0 {
263 parts.push(format_date(meta.created_at_sec));
264 }
265 if level == MetadataLevel::Full {
266 if let Some(ref vs) = meta.verification_status {
267 parts.push(vs.clone());
268 }
269 if let Some(ref ns) = meta.namespace {
270 parts.push(ns.clone());
271 }
272 }
273 if parts.is_empty() {
274 String::new()
275 } else {
276 format!(" [{}]", parts.join(", "))
277 }
278}
279
280fn sml_escape(s: &str) -> String {
285 s.replace('&', "&")
286 .replace('<', "<")
287 .replace('>', ">")
288 .replace('"', """)
289 .replace('\'', "'")
290}
291
292fn render_content_blocks_sml(blocks: &serde_json::Value, fallback: &str) -> String {
298 let Some(arr) = blocks.as_array() else {
299 return sml_escape(fallback);
300 };
301 let mut out = String::new();
302 for block in arr {
303 let Some(obj) = block.as_object() else {
304 continue;
305 };
306 let kind = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
307 match kind {
308 "text" => {
309 let text = obj.get("text").and_then(|v| v.as_str()).unwrap_or("");
310 out.push_str(&format!("<text>{}</text>", sml_escape(text)));
311 }
312 "tool_use" => {
313 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
314 let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("");
315 let input_json = obj
316 .get("input")
317 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
318 .unwrap_or_else(|| "{}".into());
319 out.push_str(&format!(
320 "<tool_use name=\"{}\" id=\"{}\"><args format=\"json\">{}</args></tool_use>",
321 sml_escape(name),
322 sml_escape(id),
323 sml_escape(&input_json)
324 ));
325 }
326 "tool_result" => {
327 let tool_use_id = obj
328 .get("tool_use_id")
329 .and_then(|v| v.as_str())
330 .unwrap_or("");
331 let content = obj.get("content").and_then(|v| v.as_str()).unwrap_or("");
332 let is_error = obj
333 .get("is_error")
334 .and_then(|v| v.as_bool())
335 .unwrap_or(false);
336 out.push_str(&format!(
337 "<tool_result tool_use_id=\"{}\" is_error=\"{}\">{}</tool_result>",
338 sml_escape(tool_use_id),
339 is_error,
340 sml_escape(content)
341 ));
342 }
343 _ => {}
344 }
345 }
346 if out.is_empty() {
347 sml_escape(fallback)
348 } else {
349 out
350 }
351}
352
353fn toon_escape(s: &str) -> String {
356 if s.is_empty() {
357 return "\"\"".to_string();
358 }
359 let needs_quoting = s != s.trim()
360 || matches!(s, "true" | "false" | "null")
361 || toon_looks_numeric(s)
362 || s.contains(':')
363 || s.contains('"')
364 || s.contains('\\')
365 || s.contains('[')
366 || s.contains(']')
367 || s.contains('{')
368 || s.contains('}')
369 || s.contains(',')
370 || s.contains('\n')
371 || s.contains('\r')
372 || s.contains('\t')
373 || s.starts_with('-');
374 if needs_quoting {
375 let escaped = s
376 .replace('\\', "\\\\")
377 .replace('"', "\\\"")
378 .replace('\n', "\\n")
379 .replace('\r', "\\r")
380 .replace('\t', "\\t");
381 format!("\"{}\"", escaped)
382 } else {
383 s.to_string()
384 }
385}
386
387fn toon_looks_numeric(s: &str) -> bool {
389 let s = s.strip_prefix('-').unwrap_or(s);
390 if s.is_empty() {
391 return false;
392 }
393 if s.len() > 1
395 && s.starts_with('0')
396 && !s.starts_with("0.")
397 && !s.starts_with("0e")
398 && !s.starts_with("0E")
399 {
400 return true;
401 }
402 s.parse::<f64>().is_ok()
403 && s.chars()
404 .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
405}
406
407pub fn toon_columns(gt: &GrainType) -> &'static [&'static str] {
413 areev_core::types::registry::meta(*gt).toon_columns
414}
415
416fn toon_canonicalize_number(n: f64) -> String {
419 if n.is_nan() || n.is_infinite() {
420 return "null".to_string();
421 }
422 format!("{}", n)
424}
425
426fn truncate(s: &str, max_chars: usize) -> &str {
428 if s.len() <= max_chars {
429 s
430 } else {
431 let mut end = max_chars.saturating_sub(3).min(s.len());
432 while end > 0 && !s.is_char_boundary(end) {
434 end -= 1;
435 }
436 &s[..end]
437 }
438}
439
440struct FactRenderer;
445
446impl GrainRenderer for FactRenderer {
447 fn grain_type(&self) -> GrainType {
448 GrainType::Fact
449 }
450
451 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
452 let subject = grain.get_str("subject").unwrap_or("?");
453 let relation = grain.get_str("relation").unwrap_or("?");
454 let object = grain.get_str("object").unwrap_or("?");
455 let meta = extract_metadata(grain, policy.metadata);
456
457 match &policy.format {
458 OutputFormat::Sml => {
459 let attrs = meta
460 .as_ref()
461 .map(|m| format_meta_sml_attrs(m, policy.metadata))
462 .unwrap_or_default();
463 format!(
464 "<fact{attrs}>{} {} {}</fact>",
465 sml_escape(subject),
466 sml_escape(relation),
467 sml_escape(object)
468 )
469 }
470 OutputFormat::Markdown => {
471 let suffix = meta
472 .as_ref()
473 .map(|m| format_meta_markdown(m, policy.metadata))
474 .unwrap_or_default();
475 format!("**{subject}** {relation} {object}{suffix}")
476 }
477 OutputFormat::PlainText => {
478 let suffix = meta
479 .as_ref()
480 .map(|m| format_meta_plain(m, policy.metadata))
481 .unwrap_or_default();
482 format!("{subject} {relation} {object}{suffix}")
483 }
484 OutputFormat::Json => {
485 let mut obj = serde_json::json!({
486 "type": "fact",
487 "subject": subject,
488 "relation": relation,
489 "object": object,
490 });
491 if let Some(ref m) = meta {
492 add_json_metadata(&mut obj, m, policy.metadata);
493 }
494 obj.to_string()
495 }
496 OutputFormat::Toon => {
497 let content = if !relation.is_empty() && !object.is_empty() {
499 format!("{relation} {object}")
500 } else if !object.is_empty() {
501 object.to_string()
502 } else {
503 relation.to_string()
504 };
505 let confidence_str = grain
506 .get_f64("confidence")
507 .map(toon_canonicalize_number)
508 .unwrap_or_default();
509 format!(
510 "{},{},{}",
511 toon_escape(subject),
512 toon_escape(&content),
513 confidence_str
514 )
515 }
516 }
517 }
518
519 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
520 let s = grain.get_str("subject").unwrap_or("?");
521 let r = grain.get_str("relation").unwrap_or("?");
522 let o = grain.get_str("object").unwrap_or("?");
523 format!("{s} {r} {o}")
524 }
525
526 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
527 adjusted_priority(0.7, grain, hit)
528 }
529}
530
531struct EventRenderer;
536
537impl GrainRenderer for EventRenderer {
538 fn grain_type(&self) -> GrainType {
539 GrainType::Event
540 }
541
542 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
543 let content = grain.get_str("content").unwrap_or("");
544 let display = truncate(content, 500);
545 let meta = extract_metadata(grain, policy.metadata);
546
547 match &policy.format {
548 OutputFormat::Sml => {
549 let attrs = meta
550 .as_ref()
551 .map(|m| format_meta_sml_attrs(m, policy.metadata))
552 .unwrap_or_default();
553 if let Some(blocks_json) = grain.fields.get("content_blocks") {
554 let inner = render_content_blocks_sml(blocks_json, display);
555 format!("<event{attrs}>{inner}</event>")
556 } else {
557 format!("<event{attrs}>{}</event>", sml_escape(display))
558 }
559 }
560 OutputFormat::Markdown => {
561 let suffix = meta
562 .as_ref()
563 .map(|m| format_meta_markdown(m, policy.metadata))
564 .unwrap_or_default();
565 format!("{display}{suffix}")
566 }
567 OutputFormat::PlainText => {
568 let suffix = meta
569 .as_ref()
570 .map(|m| format_meta_plain(m, policy.metadata))
571 .unwrap_or_default();
572 format!("{display}{suffix}")
573 }
574 OutputFormat::Json => {
575 let mut obj = serde_json::json!({
576 "type": "event",
577 "content": display,
578 });
579 if let Some(ref m) = meta {
580 add_json_metadata(&mut obj, m, policy.metadata);
581 }
582 obj.to_string()
583 }
584 OutputFormat::Toon => {
585 let role = grain
587 .get_str("role")
588 .or_else(|| grain.get_str("speaker"))
589 .unwrap_or("user");
590 let time = if grain.header.created_at_sec > 0 {
591 format_date(grain.header.created_at_sec)
592 } else {
593 String::new()
594 };
595 format!(
596 "{},{},{}",
597 toon_escape(role),
598 toon_escape(&time),
599 toon_escape(display)
600 )
601 }
602 }
603 }
604
605 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
606 let content = grain.get_str("content").unwrap_or("");
607 truncate(content, 80).to_string()
608 }
609
610 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
611 adjusted_priority(0.6, grain, hit)
612 }
613}
614
615struct StateRenderer;
620
621impl GrainRenderer for StateRenderer {
622 fn grain_type(&self) -> GrainType {
623 GrainType::State
624 }
625
626 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
627 let label = state_label(grain);
628 let meta = extract_metadata(grain, policy.metadata);
629
630 match &policy.format {
631 OutputFormat::Sml => {
632 let attrs = meta
633 .as_ref()
634 .map(|m| format_meta_sml_attrs(m, policy.metadata))
635 .unwrap_or_default();
636 format!("<state{attrs}>{}</state>", sml_escape(&label))
637 }
638 OutputFormat::Markdown => {
639 let suffix = meta
640 .as_ref()
641 .map(|m| format_meta_markdown(m, policy.metadata))
642 .unwrap_or_default();
643 format!("**State**: {label}{suffix}")
644 }
645 OutputFormat::PlainText => {
646 let suffix = meta
647 .as_ref()
648 .map(|m| format_meta_plain(m, policy.metadata))
649 .unwrap_or_default();
650 format!("State: {label}{suffix}")
651 }
652 OutputFormat::Json => {
653 let mut obj = serde_json::json!({
654 "type": "state",
655 "label": label,
656 });
657 if let Some(ctx) = grain.fields.get("context") {
660 obj["context"] = ctx.clone();
661 }
662 for key in ["plan", "history"] {
665 if let Some(v) = grain.fields.get(key) {
666 obj[key] = v.clone();
667 }
668 }
669 if let Some(ref m) = meta {
670 add_json_metadata(&mut obj, m, policy.metadata);
671 }
672 obj.to_string()
673 }
674 OutputFormat::Toon => {
675 let content_summary = if let Some(ctx) = grain.fields.get("context") {
677 if let Some(obj) = ctx.as_object() {
678 let summary: Vec<String> = obj
679 .iter()
680 .filter(|(k, _)| {
681 !matches!(k.as_str(), "label" | "description" | "title" | "name")
682 })
683 .take(3)
684 .map(|(k, v)| {
685 let val = match v {
686 serde_json::Value::String(s) => s.clone(),
687 _ => v.to_string(),
688 };
689 format!("{k}={val}")
690 })
691 .collect();
692 if summary.is_empty() {
693 label.clone()
694 } else {
695 summary.join("; ")
696 }
697 } else {
698 label.clone()
699 }
700 } else {
701 label.clone()
702 };
703 format!("{},{}", toon_escape(&label), toon_escape(&content_summary))
704 }
705 }
706 }
707
708 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
709 state_label(grain)
710 }
711
712 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
713 adjusted_priority(0.9, grain, hit)
714 }
715}
716
717fn state_label(grain: &DeserializedGrain) -> String {
718 if let Some(ctx) = grain.fields.get("context") {
721 for key in &["label", "description", "title", "name"] {
722 if let Some(s) = ctx.get(key).and_then(|v| v.as_str()) {
723 if !s.is_empty() {
724 return s.to_string();
725 }
726 }
727 }
728 if let Some(obj) = ctx.as_object() {
730 return format!("{} keys", obj.len());
731 }
732 }
733 "state".to_string()
734}
735
736struct WorkflowRenderer;
741
742const WORKFLOW_EDGE_RENDER_CAP: usize = 12;
746
747fn workflow_topology(grain: &DeserializedGrain) -> String {
751 let Some(edges) = grain.fields.get("edges").and_then(|v| v.as_array()) else {
752 return grain
754 .fields
755 .get("nodes")
756 .and_then(|v| v.as_array())
757 .map(|arr| {
758 arr.iter()
759 .filter_map(|n| n.as_str())
760 .collect::<Vec<_>>()
761 .join(", ")
762 })
763 .unwrap_or_default();
764 };
765
766 let mut parts: Vec<String> = Vec::new();
767 for e in edges.iter().take(WORKFLOW_EDGE_RENDER_CAP) {
768 let (Some(src), Some(dst)) = (
769 e.get("src").and_then(|v| v.as_str()),
770 e.get("dst").and_then(|v| v.as_str()),
771 ) else {
772 continue;
773 };
774 let mut step = format!("{src} -> {dst}");
775 if let Some(cond) = e.get("cond").and_then(|v| v.as_str()) {
776 step.push_str(&format!(" [when {cond}]"));
777 }
778 if let Some(mc) = e.get("max_cycles").and_then(|v| v.as_u64()) {
779 step.push_str(&format!(" [max {mc}x]"));
780 }
781 parts.push(step);
782 }
783 let elided = edges.len().saturating_sub(WORKFLOW_EDGE_RENDER_CAP);
784 if elided > 0 {
785 parts.push(format!("… +{elided} more"));
786 }
787 parts.join("; ")
788}
789
790fn workflow_bindings(grain: &DeserializedGrain) -> Option<String> {
792 let obj = grain.fields.get("bindings")?.as_object()?;
793 if obj.is_empty() {
794 return None;
795 }
796 let mut pairs: Vec<String> = obj
797 .iter()
798 .filter_map(|(node, hash)| hash.as_str().map(|h| format!("{node}={h}")))
799 .collect();
800 pairs.sort(); Some(pairs.join(", "))
802}
803
804impl GrainRenderer for WorkflowRenderer {
805 fn grain_type(&self) -> GrainType {
806 GrainType::Workflow
807 }
808
809 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
810 let label = grain
812 .get_str("name")
813 .or_else(|| grain.get_str("trigger"))
814 .unwrap_or("workflow");
815 let trigger = grain.get_str("trigger").unwrap_or("workflow");
816 let node_count = grain
817 .fields
818 .get("nodes")
819 .and_then(|v| v.as_array())
820 .map(|a| a.len())
821 .unwrap_or(0);
822 let edge_count = grain
823 .fields
824 .get("edges")
825 .and_then(|v| v.as_array())
826 .map(|a| a.len())
827 .unwrap_or(0);
828 let topology = workflow_topology(grain);
829 let meta = extract_metadata(grain, policy.metadata);
830
831 match &policy.format {
832 OutputFormat::Sml => {
833 let attrs = meta
834 .as_ref()
835 .map(|m| format_meta_sml_attrs(m, policy.metadata))
836 .unwrap_or_default();
837 let bindings = workflow_bindings(grain)
838 .map(|b| format!(" bindings=\"{}\"", sml_escape(&b)))
839 .unwrap_or_default();
840 format!(
841 "<workflow trigger=\"{}\" nodes=\"{node_count}\" edges=\"{edge_count}\"{bindings}{attrs}>{}</workflow>",
842 sml_escape(trigger),
843 sml_escape(&topology)
844 )
845 }
846 OutputFormat::Markdown => {
847 let suffix = meta
848 .as_ref()
849 .map(|m| format_meta_markdown(m, policy.metadata))
850 .unwrap_or_default();
851 format!("**{label}** (on: {trigger}): {topology}{suffix}")
852 }
853 OutputFormat::PlainText => {
854 let suffix = meta
855 .as_ref()
856 .map(|m| format_meta_plain(m, policy.metadata))
857 .unwrap_or_default();
858 format!("{label} (on: {trigger}): {topology}{suffix}")
859 }
860 OutputFormat::Json => {
861 let mut obj = serde_json::json!({
863 "type": "workflow",
864 "trigger": trigger,
865 "node_count": node_count,
866 "edge_count": edge_count,
867 });
868 if let Some(n) = grain.get_str("name") {
869 obj["name"] = serde_json::Value::String(n.to_string());
870 }
871 for key in ["nodes", "edges", "bindings", "retries"] {
872 if let Some(v) = grain.fields.get(key) {
873 obj[key] = v.clone();
874 }
875 }
876 if let Some(ref m) = meta {
877 add_json_metadata(&mut obj, m, policy.metadata);
878 }
879 obj.to_string()
880 }
881 OutputFormat::Toon => {
882 format!("{},{}", toon_escape(trigger), toon_escape(&topology))
884 }
885 }
886 }
887
888 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
889 let trigger = grain.get_str("trigger").unwrap_or("workflow");
890 let node_count = grain
891 .fields
892 .get("nodes")
893 .and_then(|v| v.as_array())
894 .map(|a| a.len())
895 .unwrap_or(0);
896 let edge_count = grain
897 .fields
898 .get("edges")
899 .and_then(|v| v.as_array())
900 .map(|a| a.len())
901 .unwrap_or(0);
902 format!("{trigger} ({node_count} nodes, {edge_count} edges)")
903 }
904
905 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
906 adjusted_priority(0.5, grain, hit)
907 }
908}
909
910struct ToolRenderer;
915
916impl GrainRenderer for ToolRenderer {
917 fn grain_type(&self) -> GrainType {
918 GrainType::Tool
919 }
920
921 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
922 let tool = grain.get_str("tool_name").unwrap_or("unknown");
923 let is_error = grain.get_bool("is_error").unwrap_or(false);
924 let status = if is_error { "FAIL" } else { "OK" };
925 let content = grain
926 .get_str("tool_content")
927 .or_else(|| grain.get_str("content"))
928 .unwrap_or("");
929 let error_msg = grain.get_str("error");
930 let duration = grain.get_u64("duration_ms");
931 let meta = extract_metadata(grain, policy.metadata);
932
933 let dur_str = duration.map(|d| format!(" ({d}ms)")).unwrap_or_default();
934 let result_str = if is_error {
935 error_msg.unwrap_or(content)
936 } else {
937 content
938 };
939
940 match &policy.format {
941 OutputFormat::Sml => {
942 let attrs = meta
943 .as_ref()
944 .map(|m| format_meta_sml_attrs(m, policy.metadata))
945 .unwrap_or_default();
946 let mut sml = format!(
947 "<tool tool=\"{}\" status=\"{}\"{attrs}>",
948 sml_escape(tool),
949 status.to_lowercase()
950 );
951 if !result_str.is_empty() {
952 sml.push_str(&sml_escape(truncate(result_str, 300)));
953 }
954 if let Some(d) = duration {
955 sml.push_str(&format!(" ({d}ms)"));
956 }
957 sml.push_str("</tool>");
958 sml
959 }
960 OutputFormat::Markdown => {
961 let suffix = meta
962 .as_ref()
963 .map(|m| format_meta_markdown(m, policy.metadata))
964 .unwrap_or_default();
965 let result_part = if !result_str.is_empty() {
966 format!(": {}", truncate(result_str, 200))
967 } else {
968 String::new()
969 };
970 format!("`{tool}` [{status}]{dur_str}{result_part}{suffix}")
971 }
972 OutputFormat::PlainText => {
973 let suffix = meta
974 .as_ref()
975 .map(|m| format_meta_plain(m, policy.metadata))
976 .unwrap_or_default();
977 let result_part = if !result_str.is_empty() {
978 format!(": {}", truncate(result_str, 200))
979 } else {
980 String::new()
981 };
982 format!("{tool} [{status}]{dur_str}{result_part}{suffix}")
983 }
984 OutputFormat::Json => {
985 let mut obj = serde_json::json!({
986 "type": "tool",
987 "tool_name": tool,
988 "status": status.to_lowercase(),
989 });
990 if !result_str.is_empty() {
991 obj["content"] = serde_json::Value::String(result_str.to_string());
992 }
993 if let Some(d) = duration {
994 obj["duration_ms"] = serde_json::json!(d);
995 }
996 if let Some(e) = error_msg {
997 obj["error"] = serde_json::Value::String(e.to_string());
998 }
999 if let Some(ref m) = meta {
1000 add_json_metadata(&mut obj, m, policy.metadata);
1001 }
1002 obj.to_string()
1003 }
1004 OutputFormat::Toon => {
1005 let phase = status.to_lowercase();
1007 let content_val = if !result_str.is_empty() {
1008 truncate(result_str, 300)
1009 } else {
1010 ""
1011 };
1012 format!(
1013 "{},{},{}",
1014 toon_escape(tool),
1015 toon_escape(&phase),
1016 toon_escape(content_val)
1017 )
1018 }
1019 }
1020 }
1021
1022 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1023 let tool = grain.get_str("tool_name").unwrap_or("unknown");
1024 let is_error = grain.get_bool("is_error").unwrap_or(false);
1025 let status = if is_error { "FAIL" } else { "OK" };
1026 format!("{tool} [{status}]")
1027 }
1028
1029 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1030 let is_error = grain.get_bool("is_error").unwrap_or(false);
1031 let error_boost = if is_error { 0.15 } else { 0.0 };
1032 adjusted_priority(0.5 + error_boost, grain, hit)
1033 }
1034}
1035
1036struct ObservationRenderer;
1041
1042impl GrainRenderer for ObservationRenderer {
1043 fn grain_type(&self) -> GrainType {
1044 GrainType::Observation
1045 }
1046
1047 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1048 let observer = grain.get_str("observer_id").unwrap_or("?");
1049 let subject = grain.get_str("subject").unwrap_or("");
1050 let object = grain.get_str("object").unwrap_or("");
1051 let meta = extract_metadata(grain, policy.metadata);
1052
1053 let content = if !subject.is_empty() && !object.is_empty() {
1054 format!("{subject}: {object}")
1055 } else if !subject.is_empty() {
1056 subject.to_string()
1057 } else if !object.is_empty() {
1058 object.to_string()
1059 } else {
1060 String::new()
1061 };
1062
1063 match &policy.format {
1064 OutputFormat::Sml => {
1065 let attrs = meta
1066 .as_ref()
1067 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1068 .unwrap_or_default();
1069 format!(
1070 "<observation observer=\"{}\"{attrs}>{}</observation>",
1071 sml_escape(observer),
1072 sml_escape(&content)
1073 )
1074 }
1075 OutputFormat::Markdown => {
1076 let suffix = meta
1077 .as_ref()
1078 .map(|m| format_meta_markdown(m, policy.metadata))
1079 .unwrap_or_default();
1080 if content.is_empty() {
1081 format!("*{observer}*{suffix}")
1082 } else {
1083 format!("*{observer}*: {content}{suffix}")
1084 }
1085 }
1086 OutputFormat::PlainText => {
1087 let suffix = meta
1088 .as_ref()
1089 .map(|m| format_meta_plain(m, policy.metadata))
1090 .unwrap_or_default();
1091 if content.is_empty() {
1092 format!("{observer}{suffix}")
1093 } else {
1094 format!("{observer}: {content}{suffix}")
1095 }
1096 }
1097 OutputFormat::Json => {
1098 let mut obj = serde_json::json!({
1099 "type": "observation",
1100 "observer_id": observer,
1101 });
1102 if !subject.is_empty() {
1103 obj["subject"] = serde_json::Value::String(subject.to_string());
1104 }
1105 if !object.is_empty() {
1106 obj["object"] = serde_json::Value::String(object.to_string());
1107 }
1108 if let Some(ref m) = meta {
1109 add_json_metadata(&mut obj, m, policy.metadata);
1110 }
1111 obj.to_string()
1112 }
1113 OutputFormat::Toon => {
1114 format!("{},{}", toon_escape(observer), toon_escape(&content))
1116 }
1117 }
1118 }
1119
1120 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1121 let observer = grain.get_str("observer_id").unwrap_or("?");
1122 let subject = grain.get_str("subject").unwrap_or("");
1123 if subject.is_empty() {
1124 observer.to_string()
1125 } else {
1126 format!("{observer}: {subject}")
1127 }
1128 }
1129
1130 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1131 adjusted_priority(0.6, grain, hit)
1132 }
1133}
1134
1135struct GoalRenderer;
1140
1141impl GrainRenderer for GoalRenderer {
1142 fn grain_type(&self) -> GrainType {
1143 GrainType::Goal
1144 }
1145
1146 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1147 let desc = grain.get_str("description").unwrap_or("?");
1148 let state = grain.get_str("goal_state").unwrap_or("active");
1149 let priority = grain.get_str("priority").unwrap_or("medium");
1150 let progress = grain.get_f64("progress");
1151 let criteria = grain.get_str("criteria");
1152 let meta = extract_metadata(grain, policy.metadata);
1153
1154 let progress_str = progress
1155 .map(|p| format!(" ({:.0}%)", p * 100.0))
1156 .unwrap_or_default();
1157
1158 match &policy.format {
1159 OutputFormat::Sml => {
1160 let attrs = meta
1161 .as_ref()
1162 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1163 .unwrap_or_default();
1164 let mut sml = format!(
1165 "<goal state=\"{}\" priority=\"{}\"{attrs}>{}</goal>",
1166 sml_escape(state),
1167 sml_escape(priority),
1168 sml_escape(desc)
1169 );
1170 if let Some(c) = criteria {
1171 sml = sml.replace(
1173 "</goal>",
1174 &format!("<criteria>{}</criteria></goal>", sml_escape(c)),
1175 );
1176 }
1177 sml
1178 }
1179 OutputFormat::Markdown => {
1180 let suffix = meta
1181 .as_ref()
1182 .map(|m| format_meta_markdown(m, policy.metadata))
1183 .unwrap_or_default();
1184 format!("[{priority}/{state}] {desc}{progress_str}{suffix}")
1185 }
1186 OutputFormat::PlainText => {
1187 let suffix = meta
1188 .as_ref()
1189 .map(|m| format_meta_plain(m, policy.metadata))
1190 .unwrap_or_default();
1191 format!("[{priority}/{state}] {desc}{progress_str}{suffix}")
1192 }
1193 OutputFormat::Json => {
1194 let mut obj = serde_json::json!({
1195 "type": "goal",
1196 "description": desc,
1197 "goal_state": state,
1198 "priority": priority,
1199 });
1200 if let Some(p) = progress {
1201 obj["progress"] = serde_json::json!(p);
1202 }
1203 if let Some(c) = criteria {
1204 obj["criteria"] = serde_json::Value::String(c.to_string());
1205 }
1206 if let Some(ref m) = meta {
1207 add_json_metadata(&mut obj, m, policy.metadata);
1208 }
1209 obj.to_string()
1210 }
1211 OutputFormat::Toon => {
1212 let subject = grain.get_str("subject").unwrap_or(desc);
1214 format!(
1215 "{},{},{}",
1216 toon_escape(subject),
1217 toon_escape(desc),
1218 toon_escape(state)
1219 )
1220 }
1221 }
1222 }
1223
1224 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1225 let desc = grain.get_str("description").unwrap_or("?");
1226 let state = grain.get_str("goal_state").unwrap_or("active");
1227 let priority = grain.get_str("priority").unwrap_or("medium");
1228 format!("[{priority}/{state}] {desc}")
1229 }
1230
1231 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1232 let state_mod = match grain.get_str("goal_state") {
1233 Some("active") => 0.15,
1234 Some("suspended") => 0.05,
1235 Some("satisfied" | "failed") => -0.2,
1236 _ => 0.0,
1237 };
1238 let priority_mod = match grain.get_str("priority") {
1239 Some("critical") => 0.1,
1240 Some("high") => 0.05,
1241 Some("low") => -0.1,
1242 _ => 0.0,
1243 };
1244 adjusted_priority(0.8 + state_mod + priority_mod, grain, hit)
1245 }
1246}
1247
1248struct ReasoningRenderer;
1253
1254impl GrainRenderer for ReasoningRenderer {
1255 fn grain_type(&self) -> GrainType {
1256 GrainType::Reasoning
1257 }
1258
1259 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1260 let conclusion = grain.get_str("conclusion").unwrap_or("");
1261 let method = grain.get_str("inference_method");
1262 let premise_count = grain
1263 .fields
1264 .get("premises")
1265 .and_then(|v| v.as_array())
1266 .map(|a| a.len())
1267 .unwrap_or(0);
1268 let meta = extract_metadata(grain, policy.metadata);
1269
1270 let method_str = method.map(|m| format!(" ({m})")).unwrap_or_default();
1271
1272 match &policy.format {
1273 OutputFormat::Sml => {
1274 let attrs = meta
1275 .as_ref()
1276 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1277 .unwrap_or_default();
1278 let mut sml = format!("<reasoning{attrs}>");
1279 if !conclusion.is_empty() {
1280 sml.push_str(&format!(
1281 "<conclusion>{}</conclusion>",
1282 sml_escape(conclusion)
1283 ));
1284 }
1285 if premise_count > 0 {
1286 sml.push_str(&format!("<premises count=\"{premise_count}\"/>"));
1287 }
1288 sml.push_str("</reasoning>");
1289 sml
1290 }
1291 OutputFormat::Markdown => {
1292 let suffix = meta
1293 .as_ref()
1294 .map(|m| format_meta_markdown(m, policy.metadata))
1295 .unwrap_or_default();
1296 if conclusion.is_empty() {
1297 format!("Reasoning{method_str} ({premise_count} premises){suffix}")
1298 } else {
1299 format!("{conclusion}{method_str}{suffix}")
1300 }
1301 }
1302 OutputFormat::PlainText => {
1303 let suffix = meta
1304 .as_ref()
1305 .map(|m| format_meta_plain(m, policy.metadata))
1306 .unwrap_or_default();
1307 if conclusion.is_empty() {
1308 format!("Reasoning{method_str} ({premise_count} premises){suffix}")
1309 } else {
1310 format!("{conclusion}{method_str}{suffix}")
1311 }
1312 }
1313 OutputFormat::Json => {
1314 let mut obj = serde_json::json!({
1315 "type": "reasoning",
1316 "premise_count": premise_count,
1317 });
1318 if !conclusion.is_empty() {
1319 obj["conclusion"] = serde_json::Value::String(conclusion.to_string());
1320 }
1321 if let Some(m) = method {
1322 obj["inference_method"] = serde_json::Value::String(m.to_string());
1323 }
1324 if let Some(ref m) = meta {
1325 add_json_metadata(&mut obj, m, policy.metadata);
1326 }
1327 obj.to_string()
1328 }
1329 OutputFormat::Toon => {
1330 let type_val = method.unwrap_or("reasoning");
1332 let content_val = if conclusion.is_empty() {
1333 format!("{} premises", premise_count)
1334 } else {
1335 conclusion.to_string()
1336 };
1337 format!("{},{}", toon_escape(type_val), toon_escape(&content_val))
1338 }
1339 }
1340 }
1341
1342 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1343 grain
1344 .get_str("conclusion")
1345 .unwrap_or("reasoning")
1346 .to_string()
1347 }
1348
1349 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1350 adjusted_priority(0.7, grain, hit)
1351 }
1352}
1353
1354struct ConsensusRenderer;
1359
1360impl GrainRenderer for ConsensusRenderer {
1361 fn grain_type(&self) -> GrainType {
1362 GrainType::Consensus
1363 }
1364
1365 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1366 let agreed = grain.get_str("agreed_content").unwrap_or("");
1367 let agree_count = grain.get_i64("agreement_count").unwrap_or(0);
1368 let dissent_count = grain.get_i64("dissent_count").unwrap_or(0);
1369 let meta = extract_metadata(grain, policy.metadata);
1370
1371 match &policy.format {
1372 OutputFormat::Sml => {
1373 let attrs = meta
1374 .as_ref()
1375 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1376 .unwrap_or_default();
1377 format!(
1378 "<consensus agreed=\"{agree_count}\" dissent=\"{dissent_count}\"{attrs}>{}</consensus>",
1379 sml_escape(agreed)
1380 )
1381 }
1382 OutputFormat::Markdown => {
1383 let suffix = meta
1384 .as_ref()
1385 .map(|m| format_meta_markdown(m, policy.metadata))
1386 .unwrap_or_default();
1387 let vote_str = if agree_count > 0 || dissent_count > 0 {
1388 format!(" ({agree_count} agreed, {dissent_count} dissent)")
1389 } else {
1390 String::new()
1391 };
1392 if agreed.is_empty() {
1393 format!("Consensus{vote_str}{suffix}")
1394 } else {
1395 format!("{agreed}{vote_str}{suffix}")
1396 }
1397 }
1398 OutputFormat::PlainText => {
1399 let suffix = meta
1400 .as_ref()
1401 .map(|m| format_meta_plain(m, policy.metadata))
1402 .unwrap_or_default();
1403 let vote_str = if agree_count > 0 || dissent_count > 0 {
1404 format!(" ({agree_count}/{dissent_count})")
1405 } else {
1406 String::new()
1407 };
1408 if agreed.is_empty() {
1409 format!("Consensus{vote_str}{suffix}")
1410 } else {
1411 format!("{agreed}{vote_str}{suffix}")
1412 }
1413 }
1414 OutputFormat::Json => {
1415 let mut obj = serde_json::json!({
1416 "type": "consensus",
1417 "agreement_count": agree_count,
1418 "dissent_count": dissent_count,
1419 });
1420 if !agreed.is_empty() {
1421 obj["agreed_content"] = serde_json::Value::String(agreed.to_string());
1422 }
1423 if let Some(ref m) = meta {
1424 add_json_metadata(&mut obj, m, policy.metadata);
1425 }
1426 obj.to_string()
1427 }
1428 OutputFormat::Toon => {
1429 let threshold = grain.get_str("threshold").unwrap_or("-");
1431 format!(
1432 "{},{},{}",
1433 toon_escape(threshold),
1434 agree_count,
1435 toon_escape(agreed)
1436 )
1437 }
1438 }
1439 }
1440
1441 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1442 grain
1443 .get_str("agreed_content")
1444 .unwrap_or("consensus")
1445 .to_string()
1446 }
1447
1448 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1449 adjusted_priority(0.65, grain, hit)
1450 }
1451}
1452
1453struct ConsentRenderer;
1458
1459impl GrainRenderer for ConsentRenderer {
1460 fn grain_type(&self) -> GrainType {
1461 GrainType::Consent
1462 }
1463
1464 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1465 let subject = grain.get_str("subject_did").unwrap_or("?");
1466 let is_withdrawal = grain.get_bool("is_withdrawal").unwrap_or(false);
1467 let action = if is_withdrawal { "withdraws" } else { "grants" };
1468 let scope = grain.get_str("scope").unwrap_or("");
1469 let grantee = grain.get_str("grantee_did");
1470 let meta = extract_metadata(grain, policy.metadata);
1471
1472 match &policy.format {
1473 OutputFormat::Sml => {
1474 let attrs = meta
1475 .as_ref()
1476 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1477 .unwrap_or_default();
1478 let mut sml = format!(
1479 "<consent action=\"{action}\" subject=\"{}\"{attrs}>",
1480 sml_escape(subject)
1481 );
1482 if !scope.is_empty() {
1483 sml.push_str(&format!("<scope>{}</scope>", sml_escape(scope)));
1484 }
1485 if let Some(g) = grantee {
1486 sml.push_str(&format!("<grantee>{}</grantee>", sml_escape(g)));
1487 }
1488 sml.push_str("</consent>");
1489 sml
1490 }
1491 OutputFormat::Markdown => {
1492 let suffix = meta
1493 .as_ref()
1494 .map(|m| format_meta_markdown(m, policy.metadata))
1495 .unwrap_or_default();
1496 let scope_part = if scope.is_empty() {
1497 String::new()
1498 } else {
1499 format!(" for {scope}")
1500 };
1501 format!("{subject} {action}{scope_part}{suffix}")
1502 }
1503 OutputFormat::PlainText => {
1504 let suffix = meta
1505 .as_ref()
1506 .map(|m| format_meta_plain(m, policy.metadata))
1507 .unwrap_or_default();
1508 let scope_part = if scope.is_empty() {
1509 String::new()
1510 } else {
1511 format!(" for {scope}")
1512 };
1513 format!("{subject} {action}{scope_part}{suffix}")
1514 }
1515 OutputFormat::Json => {
1516 let mut obj = serde_json::json!({
1517 "type": "consent",
1518 "subject_did": subject,
1519 "action": action,
1520 });
1521 if !scope.is_empty() {
1522 obj["scope"] = serde_json::Value::String(scope.to_string());
1523 }
1524 if let Some(g) = grantee {
1525 obj["grantee_did"] = serde_json::Value::String(g.to_string());
1526 }
1527 if let Some(ref m) = meta {
1528 add_json_metadata(&mut obj, m, policy.metadata);
1529 }
1530 obj.to_string()
1531 }
1532 OutputFormat::Toon => {
1533 let grantee_val = grantee.unwrap_or("-");
1535 format!(
1536 "{},{},{},{}",
1537 toon_escape(subject),
1538 toon_escape(grantee_val),
1539 toon_escape(action),
1540 toon_escape(scope)
1541 )
1542 }
1543 }
1544 }
1545
1546 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1547 let subject = grain.get_str("subject_did").unwrap_or("?");
1548 let is_withdrawal = grain.get_bool("is_withdrawal").unwrap_or(false);
1549 let action = if is_withdrawal { "withdraws" } else { "grants" };
1550 let scope = grain.get_str("scope").unwrap_or("");
1551 if scope.is_empty() {
1552 format!("{subject} {action}")
1553 } else {
1554 format!("{subject} {action} {scope}")
1555 }
1556 }
1557
1558 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1559 adjusted_priority(0.95, grain, hit)
1561 }
1562}
1563
1564struct RecommendationRenderer;
1573
1574fn recommendation_summary(grain: &DeserializedGrain) -> String {
1579 let args = grain.fields.get("summary").and_then(|s| s.get("args"));
1580 let Some(obj) = args.and_then(|a| a.as_object()) else {
1581 return grain
1582 .get_str("target_ref")
1583 .unwrap_or("recommendation")
1584 .to_string();
1585 };
1586 let mut parts: Vec<String> = obj
1587 .iter()
1588 .map(|(k, v)| match v {
1589 serde_json::Value::String(s) => format!("{k}={s}"),
1590 other => format!("{k}={other}"),
1591 })
1592 .collect();
1593 parts.sort(); if parts.is_empty() {
1595 grain
1596 .get_str("target_ref")
1597 .unwrap_or("recommendation")
1598 .to_string()
1599 } else {
1600 parts.join(", ")
1601 }
1602}
1603
1604impl GrainRenderer for RecommendationRenderer {
1605 fn grain_type(&self) -> GrainType {
1606 GrainType::Recommendation
1607 }
1608
1609 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1610 let target = grain.get_str("target_ref").unwrap_or("");
1611 let severity = grain.get_str("severity").unwrap_or("info");
1612 let summary = recommendation_summary(grain);
1613 let meta = extract_metadata(grain, policy.metadata);
1614
1615 match &policy.format {
1616 OutputFormat::Sml => {
1617 let attrs = meta
1618 .as_ref()
1619 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1620 .unwrap_or_default();
1621 format!(
1622 "<recommendation target=\"{}\" severity=\"{}\"{attrs}>{}</recommendation>",
1623 sml_escape(target),
1624 sml_escape(severity),
1625 sml_escape(&summary)
1626 )
1627 }
1628 OutputFormat::Markdown => {
1629 let suffix = meta
1630 .as_ref()
1631 .map(|m| format_meta_markdown(m, policy.metadata))
1632 .unwrap_or_default();
1633 format!("[{severity}] {summary} → `{target}`{suffix}")
1634 }
1635 OutputFormat::PlainText => {
1636 let suffix = meta
1637 .as_ref()
1638 .map(|m| format_meta_plain(m, policy.metadata))
1639 .unwrap_or_default();
1640 format!("[{severity}] {summary} -> {target}{suffix}")
1641 }
1642 OutputFormat::Json => {
1643 let mut obj = serde_json::json!({
1644 "type": "recommendation",
1645 "target_ref": target,
1646 "severity": severity,
1647 "summary": summary,
1648 });
1649 for key in [
1652 "analyzer",
1653 "dedup_key",
1654 "proposal_cal",
1655 "proposal_edit",
1656 "proposal_data",
1657 "metric_snapshot",
1658 "evidence_query",
1659 "rec_status",
1660 ] {
1661 if let Some(v) = grain.fields.get(key) {
1662 obj[key] = v.clone();
1663 }
1664 }
1665 if let Some(ref m) = meta {
1666 add_json_metadata(&mut obj, m, policy.metadata);
1667 }
1668 obj.to_string()
1669 }
1670 OutputFormat::Toon => format!(
1671 "{},{},{}",
1672 toon_escape(target),
1673 toon_escape(severity),
1674 toon_escape(&summary)
1675 ),
1676 }
1677 }
1678
1679 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1680 format!(
1681 "[{}] {}",
1682 grain.get_str("severity").unwrap_or("info"),
1683 recommendation_summary(grain)
1684 )
1685 }
1686
1687 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1688 adjusted_priority(0.7, grain, hit)
1691 }
1692}
1693
1694struct SkillRenderer;
1695
1696impl GrainRenderer for SkillRenderer {
1697 fn grain_type(&self) -> GrainType {
1698 GrainType::Skill
1699 }
1700
1701 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1702 let name = grain.get_str("name").unwrap_or("?");
1703 let description = grain.get_str("description").unwrap_or("");
1704 let domain = grain.get_str("domain");
1705 let proficiency = grain
1707 .get_f64("proficiency")
1708 .or_else(|| grain.get_f64("confidence"));
1709 let meta = extract_metadata(grain, policy.metadata);
1710
1711 match &policy.format {
1712 OutputFormat::Sml => {
1713 let attrs = meta
1714 .as_ref()
1715 .map(|m| format_meta_sml_attrs(m, policy.metadata))
1716 .unwrap_or_default();
1717 let domain_attr = domain
1718 .map(|d| format!(" domain=\"{}\"", sml_escape(d)))
1719 .unwrap_or_default();
1720 let mut sml = format!("<skill name=\"{}\"{domain_attr}{attrs}>", sml_escape(name));
1721 if !description.is_empty() {
1722 sml.push_str(&sml_escape(description));
1723 }
1724 sml.push_str("</skill>");
1725 sml
1726 }
1727 OutputFormat::Markdown => {
1728 let suffix = meta
1729 .as_ref()
1730 .map(|m| format_meta_markdown(m, policy.metadata))
1731 .unwrap_or_default();
1732 let domain_part = domain.map(|d| format!(" [{d}]")).unwrap_or_default();
1733 format!("**{name}**: {description}{domain_part}{suffix}")
1734 }
1735 OutputFormat::PlainText => {
1736 let suffix = meta
1737 .as_ref()
1738 .map(|m| format_meta_plain(m, policy.metadata))
1739 .unwrap_or_default();
1740 let domain_part = domain.map(|d| format!(" [{d}]")).unwrap_or_default();
1741 format!("{name}: {description}{domain_part}{suffix}")
1742 }
1743 OutputFormat::Json => {
1744 let mut obj = serde_json::json!({
1745 "type": "skill",
1746 "name": name,
1747 "description": description,
1748 });
1749 if let Some(d) = domain {
1750 obj["domain"] = serde_json::Value::String(d.to_string());
1751 }
1752 if let Some(p) = proficiency {
1753 obj["proficiency"] = serde_json::json!(p);
1754 }
1755 if let Some(ref m) = meta {
1756 add_json_metadata(&mut obj, m, policy.metadata);
1757 }
1758 obj.to_string()
1759 }
1760 OutputFormat::Toon => {
1761 let prof_str = proficiency
1763 .map(toon_canonicalize_number)
1764 .unwrap_or_default();
1765 format!(
1766 "{},{},{}",
1767 toon_escape(name),
1768 toon_escape(domain.unwrap_or("-")),
1769 toon_escape(&prof_str)
1770 )
1771 }
1772 }
1773 }
1774
1775 fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1776 let name = grain.get_str("name").unwrap_or("?");
1777 match grain.get_str("domain") {
1778 Some(d) => format!("{name} [{d}]"),
1779 None => name.to_string(),
1780 }
1781 }
1782
1783 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1784 adjusted_priority(0.6, grain, hit)
1785 }
1786}
1787
1788fn add_json_metadata(obj: &mut serde_json::Value, meta: &MetadataFragment, level: MetadataLevel) {
1793 if let Some(c) = meta.confidence {
1794 obj["confidence"] = serde_json::json!(c);
1795 }
1796 if meta.created_at_sec > 0 {
1797 obj["created_at"] = serde_json::Value::String(format_date(meta.created_at_sec));
1798 }
1799 if level == MetadataLevel::Full {
1800 obj["hash"] = serde_json::Value::String(meta.hash_hex[..16].to_string());
1801 if let Some(ref ns) = meta.namespace {
1802 obj["namespace"] = serde_json::Value::String(ns.clone());
1803 }
1804 if let Some(ref vs) = meta.verification_status {
1805 obj["verification_status"] = serde_json::Value::String(vs.clone());
1806 }
1807 if !meta.tags.is_empty() {
1808 obj["tags"] = serde_json::json!(meta.tags);
1809 }
1810 if let Some(ref st) = meta.source_type {
1811 obj["source_type"] = serde_json::Value::String(st.clone());
1812 }
1813 }
1814}
1815
1816#[cfg(test)]
1821mod tests {
1822 use super::*;
1823 use areev_core::format::deserialize::DeserializedGrain;
1824 use areev_core::format::header::MgHeader;
1825 use std::collections::HashMap;
1826
1827 fn test_grain(gt: GrainType, fields: Vec<(&str, serde_json::Value)>) -> DeserializedGrain {
1829 let mut map = HashMap::new();
1830 for (k, v) in fields {
1831 map.insert(k.to_string(), v);
1832 }
1833 DeserializedGrain {
1834 header: MgHeader {
1835 version: 1,
1836 flags: 0,
1837 grain_type: gt.type_byte(),
1838 ns_hash: 0,
1839 created_at_sec: 1740000000, },
1841 grain_type: gt,
1842 fields: map,
1843 hash: areev_core::error::Hash::from_bytes(&[0u8; 32]),
1844 }
1845 }
1846
1847 fn test_hit(grain: DeserializedGrain) -> SearchHit {
1848 SearchHit {
1849 hash: grain.hash,
1850 score: 0.85,
1851 grain,
1852 score_breakdown: None,
1853 #[cfg(feature = "rerank")]
1854 rerank_score: None,
1855 #[cfg(feature = "llm-rerank")]
1856 llm_rerank_score: None,
1857 explanation: None,
1858 scope_depth: None,
1859 source_namespace: None,
1860 relative_time: None,
1861 conflict_status: None,
1862 supersession_status: None,
1863 superseded_by_hash: None,
1864 recall_source: None,
1865 }
1866 }
1867
1868 #[test]
1869 fn test_fact_sml() {
1870 let grain = test_grain(
1871 GrainType::Fact,
1872 vec![
1873 ("subject", serde_json::json!("john")),
1874 ("relation", serde_json::json!("likes")),
1875 ("object", serde_json::json!("coffee")),
1876 ("confidence", serde_json::json!(0.95)),
1877 ],
1878 );
1879 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Minimal);
1880 let registry = RendererRegistry::new();
1881 let renderer = registry.get(GrainType::Fact).unwrap();
1882 let result = renderer.render(&grain, &policy);
1883 assert!(result.starts_with("<fact"));
1884 assert!(result.contains("confidence=\"0.95\""));
1885 assert!(result.contains("john likes coffee"));
1886 assert!(result.ends_with("</fact>"));
1887 }
1888
1889 #[test]
1890 fn test_fact_markdown() {
1891 let grain = test_grain(
1892 GrainType::Fact,
1893 vec![
1894 ("subject", serde_json::json!("john")),
1895 ("relation", serde_json::json!("likes")),
1896 ("object", serde_json::json!("coffee")),
1897 ],
1898 );
1899 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
1900 let registry = RendererRegistry::new();
1901 let result = registry
1902 .get(GrainType::Fact)
1903 .unwrap()
1904 .render(&grain, &policy);
1905 assert_eq!(result, "**john** likes coffee");
1906 }
1907
1908 #[test]
1909 fn test_fact_summary() {
1910 let grain = test_grain(
1911 GrainType::Fact,
1912 vec![
1913 ("subject", serde_json::json!("john")),
1914 ("relation", serde_json::json!("likes")),
1915 ("object", serde_json::json!("coffee")),
1916 ],
1917 );
1918 let policy = FormatPolicy::default();
1919 let registry = RendererRegistry::new();
1920 let result = registry
1921 .get(GrainType::Fact)
1922 .unwrap()
1923 .render_summary(&grain, &policy);
1924 assert_eq!(result, "john likes coffee");
1925 }
1926
1927 #[test]
1928 fn test_tool_sml() {
1929 let grain = test_grain(
1930 GrainType::Tool,
1931 vec![
1932 ("tool_name", serde_json::json!("search_api")),
1933 ("is_error", serde_json::json!(false)),
1934 ("tool_content", serde_json::json!("12 results")),
1935 ("duration_ms", serde_json::json!(340)),
1936 ],
1937 );
1938 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1939 let registry = RendererRegistry::new();
1940 let result = registry
1941 .get(GrainType::Tool)
1942 .unwrap()
1943 .render(&grain, &policy);
1944 assert!(result.contains("tool=\"search_api\""));
1945 assert!(result.contains("status=\"ok\""));
1946 assert!(result.contains("12 results"));
1947 assert!(result.contains("340ms"));
1948 }
1949
1950 #[test]
1951 fn test_goal_plaintext() {
1952 let grain = test_grain(
1953 GrainType::Goal,
1954 vec![
1955 ("description", serde_json::json!("Deploy v2")),
1956 ("goal_state", serde_json::json!("active")),
1957 ("priority", serde_json::json!("high")),
1958 ("progress", serde_json::json!(0.78)),
1959 ],
1960 );
1961 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1962 let registry = RendererRegistry::new();
1963 let result = registry
1964 .get(GrainType::Goal)
1965 .unwrap()
1966 .render(&grain, &policy);
1967 assert_eq!(result, "[high/active] Deploy v2 (78%)");
1968 }
1969
1970 #[test]
1971 fn test_consent_markdown() {
1972 let grain = test_grain(
1973 GrainType::Consent,
1974 vec![
1975 ("subject_did", serde_json::json!("did:john")),
1976 ("is_withdrawal", serde_json::json!(false)),
1977 ("scope", serde_json::json!("analytics")),
1978 ],
1979 );
1980 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
1981 let registry = RendererRegistry::new();
1982 let result = registry
1983 .get(GrainType::Consent)
1984 .unwrap()
1985 .render(&grain, &policy);
1986 assert_eq!(result, "did:john grants for analytics");
1987 }
1988
1989 fn workflow_fixture() -> DeserializedGrain {
1992 test_grain(
1993 GrainType::Workflow,
1994 vec![
1995 ("name", serde_json::json!("CI pipeline")),
1996 ("trigger", serde_json::json!("merge to main")),
1997 ("nodes", serde_json::json!(["build", "test", "deploy"])),
1998 (
1999 "edges",
2000 serde_json::json!([
2001 { "src": "build", "dst": "test" },
2002 { "src": "test", "dst": "deploy", "cond": "tests green" },
2003 ]),
2004 ),
2005 ("bindings", serde_json::json!({ "test": "sha256:abc" })),
2006 ],
2007 )
2008 }
2009
2010 fn render_with(grain: &DeserializedGrain, fmt: OutputFormat) -> String {
2011 let policy = FormatPolicy::new(fmt).metadata(MetadataLevel::None);
2012 RendererRegistry::new()
2013 .get(grain.grain_type)
2014 .unwrap()
2015 .render(grain, &policy)
2016 }
2017
2018 #[test]
2019 fn workflow_render_shows_topology_not_just_counts() {
2020 let g = workflow_fixture();
2021
2022 let plain = render_with(&g, OutputFormat::PlainText);
2023 assert!(
2024 plain.contains("build -> test") && plain.contains("test -> deploy"),
2025 "topology must be visible, got: {plain}"
2026 );
2027 assert!(
2028 plain.contains("CI pipeline") && plain.contains("merge to main"),
2029 "name and trigger must both appear, got: {plain}"
2030 );
2031 assert!(
2032 plain.contains("tests green"),
2033 "edge conditions decide the path — they must render: {plain}"
2034 );
2035 }
2036
2037 #[test]
2038 fn workflow_render_json_carries_the_real_structures() {
2039 let out = render_with(&workflow_fixture(), OutputFormat::Json);
2040 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
2041 assert_eq!(v["nodes"], serde_json::json!(["build", "test", "deploy"]));
2042 assert_eq!(v["edges"][1]["cond"], "tests green");
2043 assert_eq!(v["bindings"]["test"], "sha256:abc");
2044 assert_eq!(v["name"], "CI pipeline");
2045 assert_eq!(v["node_count"], 3);
2047 assert_eq!(v["edge_count"], 2);
2048 }
2049
2050 #[test]
2051 fn workflow_render_elides_very_large_graphs() {
2052 let edges: Vec<serde_json::Value> = (0..40)
2054 .map(|i| serde_json::json!({ "src": format!("n{i}"), "dst": format!("n{}", i + 1) }))
2055 .collect();
2056 let g = test_grain(
2057 GrainType::Workflow,
2058 vec![
2059 ("trigger", serde_json::json!("big")),
2060 ("edges", serde_json::Value::Array(edges)),
2061 ],
2062 );
2063 let plain = render_with(&g, OutputFormat::PlainText);
2064 assert!(plain.contains("n0 -> n1"), "got: {plain}");
2065 assert!(
2066 plain.contains("+28 more"),
2067 "must report what it elided, got: {plain}"
2068 );
2069 assert!(!plain.contains("n39 -> n40"), "cap must hold: {plain}");
2070 }
2071
2072 #[test]
2073 fn workflow_render_handles_unconnected_nodes() {
2074 let g = test_grain(
2076 GrainType::Workflow,
2077 vec![
2078 ("trigger", serde_json::json!("adhoc")),
2079 ("nodes", serde_json::json!(["a", "b"])),
2080 ],
2081 );
2082 let plain = render_with(&g, OutputFormat::PlainText);
2083 assert!(plain.contains('a') && plain.contains('b'), "got: {plain}");
2084 }
2085
2086 #[test]
2087 fn state_render_json_includes_plan_and_history() {
2088 let g = test_grain(
2089 GrainType::State,
2090 vec![
2091 ("context", serde_json::json!({ "label": "mid-run" })),
2092 ("plan", serde_json::json!(["fetch", "load"])),
2093 ("history", serde_json::json!([{ "node": "fetch" }])),
2094 ],
2095 );
2096 let out = render_with(&g, OutputFormat::Json);
2097 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
2098 assert_eq!(v["plan"], serde_json::json!(["fetch", "load"]));
2099 assert_eq!(v["history"][0]["node"], "fetch");
2100 }
2101
2102 #[test]
2103 fn test_state_label_extraction() {
2104 let grain = test_grain(
2108 GrainType::State,
2109 vec![(
2110 "context",
2111 serde_json::json!({"label": "planning_phase", "data": {}}),
2112 )],
2113 );
2114 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2115 let registry = RendererRegistry::new();
2116 let result = registry
2117 .get(GrainType::State)
2118 .unwrap()
2119 .render(&grain, &policy);
2120 assert_eq!(result, "State: planning_phase");
2121 }
2122
2123 #[test]
2124 fn test_context_priority_consent_highest() {
2125 let grain = test_grain(
2126 GrainType::Consent,
2127 vec![("subject_did", serde_json::json!("did:x"))],
2128 );
2129 let hit = test_hit(grain.clone());
2130 let registry = RendererRegistry::new();
2131 let consent_pri = registry
2132 .get(GrainType::Consent)
2133 .unwrap()
2134 .context_priority(&grain, &hit);
2135
2136 let fact_grain = test_grain(GrainType::Fact, vec![("subject", serde_json::json!("x"))]);
2137 let fact_hit = test_hit(fact_grain.clone());
2138 let fact_pri = registry
2139 .get(GrainType::Fact)
2140 .unwrap()
2141 .context_priority(&fact_grain, &fact_hit);
2142
2143 assert!(
2144 consent_pri > fact_pri,
2145 "Consent ({consent_pri}) should outrank Fact ({fact_pri})"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_json_format_includes_type() {
2151 let grain = test_grain(
2152 GrainType::Fact,
2153 vec![
2154 ("subject", serde_json::json!("john")),
2155 ("relation", serde_json::json!("likes")),
2156 ("object", serde_json::json!("coffee")),
2157 ],
2158 );
2159 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2160 let registry = RendererRegistry::new();
2161 let result = registry
2162 .get(GrainType::Fact)
2163 .unwrap()
2164 .render(&grain, &policy);
2165 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2166 assert_eq!(parsed["type"], "fact");
2167 assert_eq!(parsed["subject"], "john");
2168 }
2169
2170 #[test]
2171 fn test_format_date() {
2172 assert_eq!(format_date(1740000000), "2025-02-19");
2173 assert_eq!(format_date(0), "1970-01-01");
2174 assert_eq!(format_date(1609459200), "2021-01-01");
2175 }
2176
2177 #[test]
2178 fn test_sml_escape() {
2179 assert_eq!(sml_escape("a < b & c"), "a < b & c");
2180 assert_eq!(sml_escape("\"hello\""), ""hello"");
2181 }
2182
2183 #[test]
2184 fn test_fact_toon() {
2185 let grain = test_grain(
2186 GrainType::Fact,
2187 vec![
2188 ("subject", serde_json::json!("john")),
2189 ("relation", serde_json::json!("likes")),
2190 ("object", serde_json::json!("coffee")),
2191 ("confidence", serde_json::json!(0.95)),
2192 ],
2193 );
2194 let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::Minimal);
2195 let registry = RendererRegistry::new();
2196 let renderer = registry.get(GrainType::Fact).unwrap();
2197 let rendered = renderer.render(&grain, &policy);
2198 assert_eq!(rendered, "john,likes coffee,0.95");
2200 }
2201
2202 #[test]
2203 fn test_toon_escape() {
2204 assert_eq!(toon_escape("hello"), "hello");
2205 assert_eq!(toon_escape(""), "\"\"");
2206 assert_eq!(toon_escape("true"), "\"true\"");
2207 assert_eq!(toon_escape("has:colon"), "\"has:colon\"");
2208 assert_eq!(toon_escape("has,comma"), "\"has,comma\"");
2209 assert_eq!(toon_escape("has\"quote"), "\"has\\\"quote\"");
2210 assert_eq!(toon_escape("line\nbreak"), "\"line\\nbreak\"");
2211 assert_eq!(toon_escape("42"), "\"42\"");
2212 assert_eq!(toon_escape("simple text"), "simple text");
2213 }
2214
2215 #[test]
2216 fn test_toon_columns() {
2217 assert_eq!(
2218 toon_columns(&GrainType::Fact),
2219 &["subject", "content", "confidence"]
2220 );
2221 assert_eq!(
2222 toon_columns(&GrainType::Event),
2223 &["role", "time", "content"]
2224 );
2225 assert_eq!(
2226 toon_columns(&GrainType::Consent),
2227 &["grantor", "grantee", "action", "content"]
2228 );
2229 }
2230
2231 #[test]
2232 fn test_event_sml_renders_content_blocks_as_typed_tags() {
2233 let blocks = serde_json::json!([
2234 { "type": "text", "text": "hello <world>" },
2235 { "type": "tool_use", "id": "tu_1", "name": "search", "input": {"q": "a&b"} },
2236 { "type": "tool_result", "tool_use_id": "tu_1", "content": "done", "is_error": false },
2237 ]);
2238 let grain = test_grain(
2239 GrainType::Event,
2240 vec![
2241 ("content", serde_json::json!("fallback")),
2242 ("content_blocks", blocks),
2243 ],
2244 );
2245 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2246 let registry = RendererRegistry::new();
2247 let out = registry
2248 .get(GrainType::Event)
2249 .unwrap()
2250 .render(&grain, &policy);
2251 assert!(out.starts_with("<event"));
2252 assert!(out.contains("<text>hello <world></text>"));
2253 assert!(out.contains("<tool_use name=\"search\" id=\"tu_1\">"));
2254 assert!(out.contains("<args format=\"json\">"));
2255 assert!(out.contains("a&b"));
2256 assert!(
2257 out.contains("<tool_result tool_use_id=\"tu_1\" is_error=\"false\">done</tool_result>")
2258 );
2259 assert!(!out.contains("fallback"));
2261 }
2262
2263 #[test]
2264 fn test_event_sml_empty_blocks_falls_back_to_content() {
2265 let grain = test_grain(
2266 GrainType::Event,
2267 vec![
2268 ("content", serde_json::json!("plain text")),
2269 ("content_blocks", serde_json::json!([])),
2270 ],
2271 );
2272 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2273 let registry = RendererRegistry::new();
2274 let out = registry
2275 .get(GrainType::Event)
2276 .unwrap()
2277 .render(&grain, &policy);
2278 assert!(out.contains("plain text"));
2279 }
2280
2281 #[test]
2282 fn test_all_renderers_registered() {
2283 let registry = RendererRegistry::new();
2284 let types = [
2285 GrainType::Fact,
2286 GrainType::Event,
2287 GrainType::State,
2288 GrainType::Workflow,
2289 GrainType::Tool,
2290 GrainType::Observation,
2291 GrainType::Goal,
2292 GrainType::Reasoning,
2293 GrainType::Consensus,
2294 GrainType::Consent,
2295 ];
2296 for gt in &types {
2297 assert!(registry.get(*gt).is_some(), "Missing renderer for {:?}", gt);
2298 }
2299 }
2300
2301 #[test]
2304 fn test_sml_escape_metacharacters_and_bidi_passthrough() {
2305 assert_eq!(sml_escape("<a>"), "<a>");
2306 assert_eq!(sml_escape("a & b"), "a & b");
2307 assert_eq!(sml_escape("\"q\""), ""q"");
2308 assert_eq!(sml_escape("it's"), "it's");
2309 let bidi = "safe\u{202E}evil\u{2066}x\u{200D}y";
2312 assert_eq!(sml_escape(bidi), bidi);
2313 }
2314}