Skip to main content

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}