Skip to main content

ferro_text/
lib.rs

1//! Conversational-text renderer for Ferro service projections.
2//!
3//! `TextRenderer` projects a [`ServiceDef`] to deterministic plain text for the
4//! cleanly-mapping intents (Browse, Collect, Process, Summarize, Track), guard-filtered
5//! and verbosity-aware, with a defined fallback for Focus and Analyze.
6//!
7//! # Crate boundary
8//!
9//! This crate is the sole home of the text `Renderer` implementation.
10//! `ferro-projections` owns the trait and schema types; this crate owns the rendering logic.
11
12use std::collections::HashMap;
13use std::fmt::Write;
14
15use ferro_projections::render::{
16    field_display_name, is_system_field, BaseContext, Renderer, Verbosity,
17};
18use ferro_projections::{
19    ActionDef, Error, FieldDef, FieldMeaning, IntentScore, RenderHint, ServiceDef,
20};
21
22/// Renders a service projection to conversational plain text.
23///
24/// Dispatches on the primary intent (by index in `ctx.intent_index`), applies
25/// guard filtering via `ctx.evaluated_guards`, and respects `ctx.verbosity`.
26///
27/// Returns `Err(Error::NoIntents)` when the intent slice is empty or the index
28/// is out of bounds.
29pub struct TextRenderer;
30
31impl Renderer for TextRenderer {
32    type Output = String;
33    type Context = BaseContext;
34
35    fn render(
36        &self,
37        service: &ServiceDef,
38        intents: &[IntentScore],
39        ctx: &BaseContext,
40    ) -> Result<String, Error> {
41        let score = intents.get(ctx.intent_index).ok_or(Error::NoIntents)?;
42        let out = match score.intent.label() {
43            "browse" => render_browse(service, ctx),
44            "collect" => render_collect(service, ctx),
45            "process" => render_process(service, ctx),
46            "summarize" => render_summarize(service, ctx),
47            "track" => render_track(service, ctx),
48            "focus" => render_focus(service, ctx),
49            "analyze" => render_analyze(service, ctx),
50            _ => render_browse(service, ctx), // Custom intents: best-effort browse
51        };
52        Ok(out)
53    }
54}
55
56// --- Guard filtering (D-09) ---
57
58/// Returns `true` if the action should be rendered.
59///
60/// An action is hidden only if **any** of its `preconditions` maps to an explicit
61/// `false` in `evaluated_guards`. An absent key means the guard is unconstrained
62/// and the action renders.
63fn action_passes_guards(action: &ActionDef, evaluated_guards: &HashMap<String, bool>) -> bool {
64    action
65        .preconditions
66        .iter()
67        .all(|g| evaluated_guards.get(g.as_str()).copied().unwrap_or(true))
68}
69
70// --- render_hint helper (D-12) ---
71
72/// Returns the display string for a field, applying its `render_hint` if present.
73///
74/// - `Skip` → `None` (caller omits the field)
75/// - `AltText(s)` → `Some(s)` (use the alt text verbatim)
76/// - `None` on `ImageUrl`/`Url` → `Some("<label> (image)")` / `Some("<label> (link)")`
77/// - `None` on other fields → `Some("<label>")`
78fn render_field_value(f: &FieldDef) -> Option<String> {
79    match &f.render_hint {
80        Some(RenderHint::Skip) => None,
81        Some(RenderHint::AltText(s)) => Some(s.clone()),
82        None => {
83            let label = field_display_name(&f.name);
84            match &f.meaning {
85                FieldMeaning::ImageUrl => Some(format!("{label} (image)")),
86                FieldMeaning::Url => Some(format!("{label} (link)")),
87                _ => Some(label),
88            }
89        }
90    }
91}
92
93// --- Per-intent strategy functions ---
94
95fn render_browse(service: &ServiceDef, ctx: &BaseContext) -> String {
96    let entity = service.display_name.as_deref().unwrap_or(&service.name);
97    let mut out = String::new();
98
99    let domain_fields: Vec<&FieldDef> = service
100        .fields
101        .iter()
102        .filter(|f| !is_system_field(&f.meaning))
103        .collect();
104
105    match ctx.verbosity {
106        Verbosity::Brief => {
107            // Headline: entity + primary EntityName field only
108            let primary = domain_fields
109                .iter()
110                .find(|f| f.meaning == FieldMeaning::EntityName)
111                .map(|f| field_display_name(&f.name));
112            if let Some(primary_label) = primary {
113                let _ = writeln!(out, "{entity} — {primary_label}");
114            } else {
115                let _ = writeln!(out, "{entity}");
116            }
117        }
118        Verbosity::Full => {
119            let _ = writeln!(out, "{entity}");
120            if !domain_fields.is_empty() {
121                let _ = writeln!(out, "Fields:");
122                for f in &domain_fields {
123                    let _ = writeln!(out, "  - {}", field_display_name(&f.name));
124                }
125            }
126        }
127    }
128
129    out
130}
131
132fn render_collect(service: &ServiceDef, ctx: &BaseContext) -> String {
133    let entity = service.display_name.as_deref().unwrap_or(&service.name);
134    let writable: Vec<&FieldDef> = service
135        .fields
136        .iter()
137        .filter(|f| f.writable && !is_system_field(&f.meaning))
138        .collect();
139
140    let mut out = String::new();
141
142    match ctx.verbosity {
143        Verbosity::Brief => {
144            let _ = writeln!(out, "{entity} — {} fields to fill in", writable.len());
145        }
146        Verbosity::Full => {
147            let _ = writeln!(out, "{entity}");
148            if writable.is_empty() {
149                let _ = writeln!(out, "No fields to fill in.");
150            } else {
151                let _ = writeln!(out, "Fields to fill in:");
152                for f in &writable {
153                    let required_marker = if f.required { " (required)" } else { "" };
154                    let _ = writeln!(
155                        out,
156                        "  - {}{}",
157                        field_display_name(&f.name),
158                        required_marker
159                    );
160                }
161            }
162        }
163    }
164
165    out
166}
167
168fn render_process(service: &ServiceDef, ctx: &BaseContext) -> String {
169    let entity = service.display_name.as_deref().unwrap_or(&service.name);
170
171    // Resolve current state: ctx.current_state → sm.initial_state → "(unknown)"
172    let current_state = ctx
173        .current_state
174        .as_deref()
175        .or_else(|| {
176            service
177                .state_machine
178                .as_ref()
179                .map(|sm| sm.initial_state.as_str())
180        })
181        .unwrap_or("(unknown)");
182
183    // Guard-passing actions only
184    let passing_actions: Vec<&ActionDef> = service
185        .actions
186        .iter()
187        .filter(|a| action_passes_guards(a, &ctx.evaluated_guards))
188        .collect();
189
190    let mut out = String::new();
191
192    match ctx.verbosity {
193        Verbosity::Brief => {
194            let _ = write!(out, "{entity} — Currently: {current_state}.");
195            if !passing_actions.is_empty() {
196                let verbs: Vec<&str> = passing_actions
197                    .iter()
198                    .map(|a| a.display_name.as_deref().unwrap_or(&a.name))
199                    .collect();
200                let _ = write!(out, " You can: {}.", verbs.join(", "));
201            }
202            let _ = writeln!(out);
203        }
204        Verbosity::Full => {
205            let _ = writeln!(out, "{entity} — process");
206            let _ = writeln!(out);
207            let _ = writeln!(out, "Currently: {current_state}");
208
209            // Domain fields
210            let domain_fields: Vec<&FieldDef> = service
211                .fields
212                .iter()
213                .filter(|f| !is_system_field(&f.meaning))
214                .collect();
215            if !domain_fields.is_empty() {
216                let labels: Vec<String> = domain_fields
217                    .iter()
218                    .map(|f| field_display_name(&f.name))
219                    .collect();
220                let _ = writeln!(out, "Fields: {}", labels.join(", "));
221            }
222
223            if passing_actions.is_empty() {
224                let _ = writeln!(out, "Available actions: (none)");
225            } else {
226                let verbs: Vec<&str> = passing_actions
227                    .iter()
228                    .map(|a| a.display_name.as_deref().unwrap_or(&a.name))
229                    .collect();
230                let _ = writeln!(out, "Available actions: {}", verbs.join(", "));
231            }
232        }
233    }
234
235    out
236}
237
238fn render_summarize(service: &ServiceDef, ctx: &BaseContext) -> String {
239    let entity = service.display_name.as_deref().unwrap_or(&service.name);
240
241    let metric_fields: Vec<&FieldDef> = service
242        .fields
243        .iter()
244        .filter(|f| {
245            matches!(
246                f.meaning,
247                FieldMeaning::Money
248                    | FieldMeaning::Percentage
249                    | FieldMeaning::Quantity
250                    | FieldMeaning::Status
251            )
252        })
253        .collect();
254
255    let mut out = String::new();
256
257    match ctx.verbosity {
258        Verbosity::Brief => {
259            let first_metric = metric_fields.first().map(|f| field_display_name(&f.name));
260            if let Some(label) = first_metric {
261                let _ = writeln!(out, "{entity} — {label}");
262            } else {
263                let _ = writeln!(out, "{entity}");
264            }
265        }
266        Verbosity::Full => {
267            let _ = writeln!(out, "{entity}");
268            if metric_fields.is_empty() {
269                let _ = writeln!(out, "No metric or status fields.");
270            } else {
271                let labels: Vec<String> = metric_fields
272                    .iter()
273                    .map(|f| field_display_name(&f.name))
274                    .collect();
275                let _ = writeln!(out, "Key metrics: {}", labels.join(", "));
276            }
277        }
278    }
279
280    out
281}
282
283fn render_track(service: &ServiceDef, ctx: &BaseContext) -> String {
284    let entity = service.display_name.as_deref().unwrap_or(&service.name);
285
286    let sm = service.state_machine.as_ref();
287
288    // Resolve current state
289    let current_state = ctx
290        .current_state
291        .as_deref()
292        .or_else(|| sm.map(|s| s.initial_state.as_str()))
293        .unwrap_or("(unknown)");
294
295    let mut out = String::new();
296
297    match ctx.verbosity {
298        Verbosity::Brief => {
299            let _ = writeln!(out, "{entity} — Currently: {current_state}.");
300        }
301        Verbosity::Full => {
302            let _ = writeln!(out, "{entity}");
303            let _ = writeln!(out, "Currently: {current_state}");
304
305            // Check if current state is terminal
306            if let Some(sm) = sm {
307                let is_terminal = sm
308                    .states
309                    .iter()
310                    .find(|s| s.name == current_state)
311                    .map(|s| s.is_final)
312                    .unwrap_or(false);
313
314                if is_terminal {
315                    let _ = writeln!(out, "Status: completed (terminal state)");
316                } else {
317                    // List available transitions
318                    let transitions = sm.events_from_state(current_state);
319                    if !transitions.is_empty() {
320                        let next_states: Vec<&str> =
321                            transitions.iter().map(|t| t.to.as_str()).collect();
322                        let unique: Vec<&str> = {
323                            let mut seen = std::collections::HashSet::new();
324                            next_states
325                                .into_iter()
326                                .filter(|s| seen.insert(*s))
327                                .collect()
328                        };
329                        let _ = writeln!(out, "Next possible states: {}", unique.join(", "));
330                    }
331                }
332            }
333        }
334    }
335
336    out
337}
338
339fn render_focus(service: &ServiceDef, _ctx: &BaseContext) -> String {
340    let entity = service.display_name.as_deref().unwrap_or(&service.name);
341    let mut out = String::new();
342
343    let _ = writeln!(out, "{entity}");
344    for f in service
345        .fields
346        .iter()
347        .filter(|f| !is_system_field(&f.meaning))
348    {
349        if let Some(value) = render_field_value(f) {
350            let _ = writeln!(out, "  - {value}");
351        }
352    }
353    let _ = writeln!(
354        out,
355        "Note: This is a media/navigational view; full text representation is limited."
356    );
357
358    out
359}
360
361fn render_analyze(service: &ServiceDef, _ctx: &BaseContext) -> String {
362    let entity = service.display_name.as_deref().unwrap_or(&service.name);
363    let mut out = String::new();
364
365    let _ = writeln!(out, "{entity}");
366    let domain_fields: Vec<&FieldDef> = service
367        .fields
368        .iter()
369        .filter(|f| !is_system_field(&f.meaning))
370        .collect();
371    if !domain_fields.is_empty() {
372        let labels: Vec<String> = domain_fields
373            .iter()
374            .map(|f| field_display_name(&f.name))
375            .collect();
376        let _ = writeln!(out, "Fields: {}", labels.join(", "));
377    }
378    let _ = writeln!(
379        out,
380        "Note: Time-series and trend data has no full text representation in this channel."
381    );
382
383    out
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use ferro_projections::render::BaseContext;
390    use ferro_projections::{
391        derive_intents, ActionDef, DataType, Error, FieldMeaning, GuardDef, Intent, IntentScore,
392        RenderHint, ServiceDef, StateDef, StateMachine, Transition, Verbosity,
393    };
394
395    /// Construct a single-entry intent slice forcing a specific intent.
396    fn force_intent(intent: Intent) -> Vec<IntentScore> {
397        vec![IntentScore {
398            intent,
399            confidence: 1.0,
400            matching_signals: vec![],
401        }]
402    }
403
404    // Copied verbatim from ferro-projections/src/render/sketch/cli.rs test module (D-15)
405    fn approval_workflow_fixture() -> ServiceDef {
406        ServiceDef::new("approval_workflow")
407            .field("id", DataType::Integer, FieldMeaning::Identifier)
408            .field("title", DataType::String, FieldMeaning::EntityName)
409            .field("status", DataType::String, FieldMeaning::Status)
410            .field("amount", DataType::Float, FieldMeaning::Money)
411            .guard(GuardDef::new("has_required_fields"))
412            .guard(GuardDef::new("is_approver"))
413            .guard(GuardDef::new("is_cancellable"))
414            .state_machine(
415                StateMachine::new("approval_lifecycle")
416                    .initial("draft")
417                    .state(StateDef::new("draft"))
418                    .state(StateDef::new("submitted"))
419                    .state(StateDef::new("approved").final_state())
420                    .state(StateDef::new("rejected").final_state())
421                    .state(StateDef::new("cancelled").final_state())
422                    .transition(
423                        Transition::new("draft", "submit", "submitted")
424                            .guard("has_required_fields"),
425                    )
426                    .transition(
427                        Transition::new("submitted", "approve", "approved").guard("is_approver"),
428                    )
429                    .transition(
430                        Transition::new("submitted", "reject", "rejected").guard("is_approver"),
431                    )
432                    .transition(
433                        Transition::new("draft", "cancel", "cancelled").guard("is_cancellable"),
434                    )
435                    .transition(
436                        Transition::new("submitted", "cancel", "cancelled").guard("is_cancellable"),
437                    ),
438            )
439            .action(
440                ActionDef::new("submit")
441                    .precondition("has_required_fields")
442                    .transition_trigger("submit"),
443            )
444            .action(
445                ActionDef::new("approve")
446                    .precondition("is_approver")
447                    .transition_trigger("approve"),
448            )
449            .action(
450                ActionDef::new("reject")
451                    .precondition("is_approver")
452                    .transition_trigger("reject"),
453            )
454            .action(
455                ActionDef::new("cancel")
456                    .precondition("is_cancellable")
457                    .transition_trigger("cancel"),
458            )
459    }
460
461    // 1. Process anchor — empty guards: all four actions render
462    #[test]
463    fn process_unfiltered_renders_all_four_actions() {
464        let svc = approval_workflow_fixture();
465        let intents = derive_intents(&svc);
466        let ctx = BaseContext {
467            evaluated_guards: HashMap::new(),
468            ..Default::default()
469        };
470        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
471        insta::assert_snapshot!("process_unfiltered", result);
472        assert!(result.contains("submit"), "should contain submit");
473        assert!(result.contains("approve"), "should contain approve");
474        assert!(result.contains("reject"), "should contain reject");
475        assert!(result.contains("cancel"), "should contain cancel");
476    }
477
478    // 2. Process anchor — is_approver: false → approve/reject hidden, submit/cancel visible
479    #[test]
480    fn process_filtered_hides_approve_reject() {
481        let svc = approval_workflow_fixture();
482        let intents = derive_intents(&svc);
483        let ctx = BaseContext {
484            evaluated_guards: [("is_approver".to_string(), false)].into(),
485            ..Default::default()
486        };
487        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
488        insta::assert_snapshot!("process_filtered", result);
489        assert!(
490            !result.contains("approve"),
491            "approve should be hidden when is_approver=false"
492        );
493        assert!(
494            !result.contains("reject"),
495            "reject should be hidden when is_approver=false"
496        );
497        assert!(result.contains("submit"), "submit should remain visible");
498        assert!(result.contains("cancel"), "cancel should remain visible");
499    }
500
501    // 3. Brief differs from Full
502    #[test]
503    fn brief_differs_from_full() {
504        let svc = approval_workflow_fixture();
505        let intents = derive_intents(&svc);
506        let full_ctx = BaseContext {
507            verbosity: Verbosity::Full,
508            ..Default::default()
509        };
510        let brief_ctx = BaseContext {
511            verbosity: Verbosity::Brief,
512            ..Default::default()
513        };
514        let full_out = TextRenderer.render(&svc, &intents, &full_ctx).unwrap();
515        let brief_out = TextRenderer.render(&svc, &intents, &brief_ctx).unwrap();
516        insta::assert_snapshot!("process_full", full_out);
517        insta::assert_snapshot!("process_brief", brief_out);
518        assert!(
519            brief_out.len() < full_out.len(),
520            "Brief output should be shorter than Full"
521        );
522        assert_ne!(full_out, brief_out, "Full and Brief must differ");
523    }
524
525    // 4a. Browse intent
526    #[test]
527    fn browse_intent_snapshot() {
528        let svc = ServiceDef::new("product")
529            .display_name("Product")
530            .field("id", DataType::Integer, FieldMeaning::Identifier)
531            .field("name", DataType::String, FieldMeaning::EntityName)
532            .field("price", DataType::Float, FieldMeaning::Money)
533            .field("sku", DataType::String, FieldMeaning::Custom("sku".into()));
534        let intents = derive_intents(&svc);
535        // Force browse by finding it
536        let browse_idx = intents
537            .iter()
538            .position(|s| s.intent.label() == "browse")
539            .unwrap_or(0);
540        let ctx = BaseContext {
541            intent_index: browse_idx,
542            ..Default::default()
543        };
544        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
545        insta::assert_snapshot!("browse_full", result);
546        assert!(result.contains("Product"), "should mention entity");
547    }
548
549    // 4b. Collect intent
550    #[test]
551    fn collect_intent_snapshot() {
552        let svc = ServiceDef::new("registration")
553            .display_name("Registration")
554            .field("id", DataType::Integer, FieldMeaning::Identifier)
555            .field("email", DataType::String, FieldMeaning::Email)
556            .field("name", DataType::String, FieldMeaning::EntityName)
557            .optional_field("phone", DataType::String, FieldMeaning::Phone);
558        let intents = derive_intents(&svc);
559        let collect_idx = intents
560            .iter()
561            .position(|s| s.intent.label() == "collect")
562            .unwrap_or(0);
563        let ctx = BaseContext {
564            intent_index: collect_idx,
565            ..Default::default()
566        };
567        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
568        insta::assert_snapshot!("collect_full", result);
569        assert!(result.contains("Registration"), "should mention entity");
570    }
571
572    // 4c. Summarize intent
573    #[test]
574    fn summarize_intent_snapshot() {
575        let svc = ServiceDef::new("invoice")
576            .display_name("Invoice")
577            .field("id", DataType::Integer, FieldMeaning::Identifier)
578            .field("total", DataType::Float, FieldMeaning::Money)
579            .field("status", DataType::String, FieldMeaning::Status)
580            .field("items_count", DataType::Integer, FieldMeaning::Quantity);
581        let intents = derive_intents(&svc);
582        let summ_idx = intents
583            .iter()
584            .position(|s| s.intent.label() == "summarize")
585            .unwrap_or(0);
586        let ctx = BaseContext {
587            intent_index: summ_idx,
588            ..Default::default()
589        };
590        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
591        insta::assert_snapshot!("summarize_full", result);
592        assert!(result.contains("Invoice"), "should mention entity");
593    }
594
595    // 4d. Track intent
596    #[test]
597    fn track_intent_snapshot() {
598        let svc = ServiceDef::new("shipment")
599            .display_name("Shipment")
600            .field("id", DataType::Integer, FieldMeaning::Identifier)
601            .field(
602                "tracking_number",
603                DataType::String,
604                FieldMeaning::EntityName,
605            )
606            .state_machine(
607                StateMachine::new("shipping_lifecycle")
608                    .initial("pending")
609                    .state(StateDef::new("pending"))
610                    .state(StateDef::new("shipped"))
611                    .state(StateDef::new("delivered").final_state())
612                    .transition(Transition::new("pending", "ship", "shipped"))
613                    .transition(Transition::new("shipped", "deliver", "delivered")),
614            );
615        let intents = derive_intents(&svc);
616        let track_idx = intents
617            .iter()
618            .position(|s| s.intent.label() == "track")
619            .unwrap_or(0);
620        let ctx = BaseContext {
621            intent_index: track_idx,
622            ..Default::default()
623        };
624        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
625        insta::assert_snapshot!("track_full", result);
626        assert!(result.contains("Shipment"), "should mention entity");
627    }
628
629    // 5. ImageUrl with None hint renders with (image) label, not raw URL
630    #[test]
631    fn image_url_none_hint_labels_not_raw() {
632        // Test render_field_value directly: ImageUrl with no hint → "(image)" suffix
633        let f = ferro_projections::FieldDef {
634            name: "cover_photo".to_string(),
635            data_type: DataType::String,
636            meaning: FieldMeaning::ImageUrl,
637            required: false,
638            is_list: false,
639            readable: true,
640            writable: true,
641            render_hint: None,
642        };
643        let result = render_field_value(&f).unwrap();
644        assert!(
645            result.contains("(image)"),
646            "ImageUrl field without hint should use (image) label; got: {result}"
647        );
648        assert!(
649            !result.contains("http"),
650            "should not contain raw URL string; got: {result}"
651        );
652    }
653
654    // 6. Url with AltText hint renders the alt text
655    #[test]
656    fn url_alt_text_renders_alt() {
657        // Test render_field_value directly since .field() builder does not set render_hint
658        let f = ferro_projections::FieldDef {
659            name: "photo_url".to_string(),
660            data_type: DataType::String,
661            meaning: FieldMeaning::Url,
662            required: true,
663            is_list: false,
664            readable: true,
665            writable: true,
666            render_hint: Some(RenderHint::AltText("Photo".to_string())),
667        };
668        let result = render_field_value(&f).unwrap();
669        assert_eq!(
670            result, "Photo",
671            "AltText should render the alt text verbatim"
672        );
673        assert!(
674            !result.contains("http"),
675            "should not contain raw URL; got: {result}"
676        );
677    }
678
679    // 7. Url with Skip hint omits the field
680    #[test]
681    fn url_skip_omits_field() {
682        let f = ferro_projections::FieldDef {
683            name: "thumbnail".to_string(),
684            data_type: DataType::String,
685            meaning: FieldMeaning::Url,
686            required: false,
687            is_list: false,
688            readable: true,
689            writable: true,
690            render_hint: Some(RenderHint::Skip),
691        };
692        let result = render_field_value(&f);
693        assert!(
694            result.is_none(),
695            "Skip hint should return None; got: {result:?}"
696        );
697    }
698
699    // 8. Focus fallback — not empty, contains the limited-modality note
700    #[test]
701    fn focus_fallback_present() {
702        let svc = ServiceDef::new("profile")
703            .display_name("Profile")
704            .field("id", DataType::Integer, FieldMeaning::Identifier)
705            .field("avatar", DataType::String, FieldMeaning::ImageUrl)
706            .field("website", DataType::String, FieldMeaning::Url);
707        // Force focus intent — derive_intents may not score focus as primary for this fixture
708        let intents = force_intent(Intent::Focus);
709        let ctx = BaseContext::default();
710        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
711        insta::assert_snapshot!("focus_fallback", result);
712        assert!(!result.is_empty(), "Focus fallback must not be empty");
713        assert!(
714            result.contains("limited"),
715            "Focus fallback must contain the limited-modality note; got: {result}"
716        );
717    }
718
719    // 9. Analyze fallback — not empty, no fabricated statistics, contains the channel note
720    #[test]
721    fn analyze_fallback_present() {
722        let svc = ServiceDef::new("sales_report")
723            .display_name("Sales Report")
724            .field("id", DataType::Integer, FieldMeaning::Identifier)
725            .field("revenue", DataType::Float, FieldMeaning::Money)
726            .field("units", DataType::Integer, FieldMeaning::Quantity);
727        // Force analyze intent — structural signals (Money + Quantity) score as Summarize by default
728        let intents = force_intent(Intent::Analyze);
729        let ctx = BaseContext::default();
730        let result = TextRenderer.render(&svc, &intents, &ctx).unwrap();
731        insta::assert_snapshot!("analyze_fallback", result);
732        assert!(!result.is_empty(), "Analyze fallback must not be empty");
733        assert!(
734            result.contains("Time-series"),
735            "Analyze fallback must contain the channel note; got: {result}"
736        );
737        // Verify no fabricated numbers
738        assert!(
739            !result.contains("%"),
740            "Analyze must not fabricate statistics; got: {result}"
741        );
742    }
743
744    // 10. Empty intents returns Error::NoIntents
745    #[test]
746    fn empty_intents_returns_no_intents() {
747        let svc = approval_workflow_fixture();
748        let result = TextRenderer.render(&svc, &[], &BaseContext::default());
749        assert!(
750            matches!(result, Err(Error::NoIntents)),
751            "empty intents must return Error::NoIntents; got: {result:?}"
752        );
753    }
754}