1use 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
22pub 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), };
52 Ok(out)
53 }
54}
55
56fn 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
70fn 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
93fn 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 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 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 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 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 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 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 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 fn force_intent(intent: Intent) -> Vec<IntentScore> {
397 vec![IntentScore {
398 intent,
399 confidence: 1.0,
400 matching_signals: vec![],
401 }]
402 }
403
404 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[test]
631 fn image_url_none_hint_labels_not_raw() {
632 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 #[test]
656 fn url_alt_text_renders_alt() {
657 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 #[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 #[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 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 #[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 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 assert!(
739 !result.contains("%"),
740 "Analyze must not fabricate statistics; got: {result}"
741 );
742 }
743
744 #[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}