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
8// Research sketch — not stable API
9pub(crate) mod sketch;
10pub mod template;
11
12use crate::error::Error;
13use crate::field::FieldMeaning;
14use crate::intent::IntentScore;
15use crate::service::ServiceDef;
16use std::collections::HashMap;
17
18/// Text detail level for non-visual rendering.
19///
20/// `Full` reproduces the current full-render behavior, so it is the
21/// backward-compatible default. `Brief` is consumed by non-visual renderers
22/// (e.g., the Phase 216 conversational-text renderer) to omit secondary detail.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Verbosity {
25 /// Full detail — preserves current visual render behavior. (default)
26 #[default]
27 Full,
28 /// Reduced detail for non-visual channels.
29 Brief,
30}
31
32/// Modality-agnostic rendering context shared by all `Renderer` implementations.
33///
34/// Visual-only fields (render mode, theme templates) live in `VisualContext`
35/// inside `ferro-json-ui`.
36#[derive(Debug, Clone, Default)]
37pub struct BaseContext {
38 /// Which intent to render (0 = primary). Index into the `intents` slice.
39 pub intent_index: usize,
40 /// Current workflow state name (relevant for Process/Track intents).
41 pub current_state: Option<String>,
42 /// Guard-name → evaluated result. Absent key = render the action
43 /// (guard not-yet-evaluated / unconstrained); only an explicit `false`
44 /// filters an action out. Keyed by the strings in `ActionDef::preconditions`
45 /// / `GuardDef::name`. Default = empty map = render everything. (D-03/D-04)
46 pub evaluated_guards: HashMap<String, bool>,
47 /// Text detail level. `Full` (default) preserves current full-render
48 /// behavior; `Brief` is consumed by non-visual renderers. (D-05)
49 pub verbosity: Verbosity,
50}
51
52/// Trait for rendering a service definition into output of an associated type.
53///
54/// Implementations translate `ServiceDef` + scored intents into renderer-specific
55/// output. The associated `Output` and `Context` types allow renderers to operate
56/// on different targets (JSON-UI visual trees, template contexts, voice payloads,
57/// etc.) without coupling to any single output format.
58pub trait Renderer: Send + Sync {
59 /// The output type produced by this renderer (e.g., `serde_json::Value`).
60 type Output;
61 /// The context type consumed by this renderer. Must implement `Default`.
62 type Context: Default;
63
64 /// Renders a service definition into the renderer's output type.
65 ///
66 /// # Arguments
67 /// * `service` - The service definition to render
68 /// * `intents` - Scored intents from structural analysis (sorted by confidence)
69 /// * `ctx` - Rendering context (renderer-specific)
70 ///
71 /// # Errors
72 /// Returns `Error::Render` if the rendering process fails.
73 fn render(
74 &self,
75 service: &ServiceDef,
76 intents: &[IntentScore],
77 ctx: &Self::Context,
78 ) -> Result<Self::Output, Error>;
79}
80
81/// Converts a snake_case field name to a title case display label.
82///
83/// Splits on underscores, capitalizes each word's first character.
84///
85/// ```
86/// use ferro_projections::render::field_display_name;
87///
88/// assert_eq!(field_display_name("user_name"), "User Name");
89/// assert_eq!(field_display_name("email"), "Email");
90/// ```
91pub fn field_display_name(name: &str) -> String {
92 name.split('_')
93 .map(|word| {
94 let mut chars = word.chars();
95 match chars.next() {
96 None => String::new(),
97 Some(c) => {
98 let upper: String = c.to_uppercase().collect();
99 upper + &chars.collect::<String>()
100 }
101 }
102 })
103 .collect::<Vec<_>>()
104 .join(" ")
105}
106
107/// Returns true for system/infrastructure field meanings that should not
108/// contribute to domain intent signals or appear in user-facing layouts.
109pub fn is_system_field(meaning: &FieldMeaning) -> bool {
110 matches!(
111 meaning,
112 FieldMeaning::Identifier | FieldMeaning::CreatedAt | FieldMeaning::UpdatedAt
113 )
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn base_context_default() {
122 let ctx = BaseContext::default();
123 assert_eq!(ctx.intent_index, 0);
124 assert!(ctx.current_state.is_none());
125 assert!(ctx.evaluated_guards.is_empty());
126 assert_eq!(ctx.verbosity, Verbosity::Full);
127 }
128
129 #[test]
130 fn verbosity_default_is_full() {
131 assert_eq!(Verbosity::default(), Verbosity::Full);
132 }
133
134 #[test]
135 fn field_display_name_multi_word() {
136 assert_eq!(field_display_name("user_name"), "User Name");
137 }
138
139 #[test]
140 fn field_display_name_single_word() {
141 assert_eq!(field_display_name("email"), "Email");
142 }
143
144 #[test]
145 fn field_display_name_timestamp() {
146 assert_eq!(field_display_name("created_at"), "Created At");
147 }
148
149 #[test]
150 fn field_display_name_empty() {
151 assert_eq!(field_display_name(""), "");
152 }
153
154 #[test]
155 fn is_system_field_identifies_system_meanings() {
156 assert!(is_system_field(&FieldMeaning::Identifier));
157 assert!(is_system_field(&FieldMeaning::CreatedAt));
158 assert!(is_system_field(&FieldMeaning::UpdatedAt));
159 }
160
161 #[test]
162 fn is_system_field_rejects_domain_meanings() {
163 assert!(!is_system_field(&FieldMeaning::Money));
164 assert!(!is_system_field(&FieldMeaning::EntityName));
165 assert!(!is_system_field(&FieldMeaning::FreeText));
166 assert!(!is_system_field(&FieldMeaning::Status));
167 assert!(!is_system_field(&FieldMeaning::Custom("x".into())));
168 }
169}