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
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}