ferro_projections/render/mod.rs
1//! Rendering abstraction layer for service projections.
2//!
3//! Defines the `Renderer` trait and `BaseContext` (modality-agnostic fields)
4//! that translate `ServiceDef` + `IntentScore` into renderable output.
5//! Concrete renderer implementations live in their respective output crates
6//! (e.g., `JsonUiRenderer` in ferro-json-ui).
7
8pub mod template;
9
10use crate::error::Error;
11use crate::field::FieldMeaning;
12use crate::intent::IntentScore;
13use crate::service::ServiceDef;
14
15/// Modality-agnostic rendering context shared by all `Renderer` implementations.
16///
17/// Visual-only fields (render mode, theme templates) live in `VisualContext`
18/// inside `ferro-json-ui`.
19#[derive(Debug, Clone, Default)]
20pub struct BaseContext {
21 /// Which intent to render (0 = primary). Index into the `intents` slice.
22 pub intent_index: usize,
23 /// Current workflow state name (relevant for Process/Track intents).
24 pub current_state: Option<String>,
25}
26
27/// Trait for rendering a service definition into output of an associated type.
28///
29/// Implementations translate `ServiceDef` + scored intents into renderer-specific
30/// output. The associated `Output` and `Context` types allow renderers to operate
31/// on different targets (JSON-UI visual trees, template contexts, voice payloads,
32/// etc.) without coupling to any single output format.
33pub trait Renderer: Send + Sync {
34 /// The output type produced by this renderer (e.g., `serde_json::Value`).
35 type Output;
36 /// The context type consumed by this renderer. Must implement `Default`.
37 type Context: Default;
38
39 /// Renders a service definition into the renderer's output type.
40 ///
41 /// # Arguments
42 /// * `service` - The service definition to render
43 /// * `intents` - Scored intents from structural analysis (sorted by confidence)
44 /// * `ctx` - Rendering context (renderer-specific)
45 ///
46 /// # Errors
47 /// Returns `Error::Render` if the rendering process fails.
48 fn render(
49 &self,
50 service: &ServiceDef,
51 intents: &[IntentScore],
52 ctx: &Self::Context,
53 ) -> Result<Self::Output, Error>;
54}
55
56/// Converts a snake_case field name to a title case display label.
57///
58/// Splits on underscores, capitalizes each word's first character.
59///
60/// ```
61/// use ferro_projections::render::field_display_name;
62///
63/// assert_eq!(field_display_name("user_name"), "User Name");
64/// assert_eq!(field_display_name("email"), "Email");
65/// ```
66pub fn field_display_name(name: &str) -> String {
67 name.split('_')
68 .map(|word| {
69 let mut chars = word.chars();
70 match chars.next() {
71 None => String::new(),
72 Some(c) => {
73 let upper: String = c.to_uppercase().collect();
74 upper + &chars.collect::<String>()
75 }
76 }
77 })
78 .collect::<Vec<_>>()
79 .join(" ")
80}
81
82/// Returns true for system/infrastructure field meanings that should not
83/// contribute to domain intent signals or appear in user-facing layouts.
84pub fn is_system_field(meaning: &FieldMeaning) -> bool {
85 matches!(
86 meaning,
87 FieldMeaning::Identifier | FieldMeaning::CreatedAt | FieldMeaning::UpdatedAt
88 )
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn base_context_default() {
97 let ctx = BaseContext::default();
98 assert_eq!(ctx.intent_index, 0);
99 assert!(ctx.current_state.is_none());
100 }
101
102 #[test]
103 fn field_display_name_multi_word() {
104 assert_eq!(field_display_name("user_name"), "User Name");
105 }
106
107 #[test]
108 fn field_display_name_single_word() {
109 assert_eq!(field_display_name("email"), "Email");
110 }
111
112 #[test]
113 fn field_display_name_timestamp() {
114 assert_eq!(field_display_name("created_at"), "Created At");
115 }
116
117 #[test]
118 fn field_display_name_empty() {
119 assert_eq!(field_display_name(""), "");
120 }
121
122 #[test]
123 fn is_system_field_identifies_system_meanings() {
124 assert!(is_system_field(&FieldMeaning::Identifier));
125 assert!(is_system_field(&FieldMeaning::CreatedAt));
126 assert!(is_system_field(&FieldMeaning::UpdatedAt));
127 }
128
129 #[test]
130 fn is_system_field_rejects_domain_meanings() {
131 assert!(!is_system_field(&FieldMeaning::Money));
132 assert!(!is_system_field(&FieldMeaning::EntityName));
133 assert!(!is_system_field(&FieldMeaning::FreeText));
134 assert!(!is_system_field(&FieldMeaning::Status));
135 assert!(!is_system_field(&FieldMeaning::Custom("x".into())));
136 }
137}