Skip to main content

ftui_widgets/
choreography.rs

1//! Choreographic Programming for multi-widget interactions (bd-14k2m).
2//!
3//! Defines coordinated multi-widget behavior from a **global** specification
4//! and automatically projects it to per-widget message handlers.
5//!
6//! # Motivation
7//!
8//! In Elm/Bubbletea architecture, coordinated behavior across multiple widgets
9//! requires manually routing messages. Missing a message means inconsistent
10//! state. Choreographic programming (Montesi 2013) eliminates this class of
11//! bugs by:
12//!
13//! 1. Define the choreography once (global specification).
14//! 2. Project to individual widgets automatically.
15//! 3. Guarantee completeness (no missing messages).
16//!
17//! # Example
18//!
19//! ```
20//! use ftui_widgets::choreography::*;
21//!
22//! let mut choreo = Choreography::new("filter_update");
23//!
24//! // Global specification: when filter changes, update list + status + header
25//! choreo.step("filter", Action::Emit("filter_changed".to_string()));
26//! choreo.step(
27//!     "list",
28//!     Action::Handle("filter_changed".to_string(), "scroll_to_top".to_string()),
29//! );
30//! choreo.step(
31//!     "status",
32//!     Action::Handle("filter_changed".to_string(), "update_count".to_string()),
33//! );
34//! choreo.step(
35//!     "header",
36//!     Action::Handle("filter_changed".to_string(), "highlight_filter".to_string()),
37//! );
38//!
39//! // Project to per-widget handlers
40//! let projection = choreo.project();
41//! assert_eq!(projection.handlers("filter").len(), 1);
42//! assert_eq!(projection.handlers("list").len(), 1);
43//! assert_eq!(projection.handlers("status").len(), 1);
44//! assert_eq!(projection.handlers("header").len(), 1);
45//!
46//! // Verify deadlock freedom
47//! assert!(choreo.verify_deadlock_free());
48//! ```
49
50use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
51
52/// A participant in a choreography (typically a widget).
53pub type ParticipantId = String;
54
55/// A message type exchanged between participants.
56pub type MessageType = String;
57
58/// An action in a choreography step.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Action {
61    /// Emit a message to the choreography.
62    Emit(MessageType),
63    /// Handle a received message by executing a named handler.
64    Handle(MessageType, String),
65    /// Internal computation (no message exchange).
66    Local(String),
67}
68
69/// A single step in a choreography.
70#[derive(Debug, Clone)]
71pub struct ChoreographyStep {
72    /// Which participant performs this step.
73    pub participant: ParticipantId,
74    /// What the participant does.
75    pub action: Action,
76    /// Step index in the choreography sequence.
77    pub sequence: usize,
78}
79
80/// A global choreography specification.
81#[derive(Debug, Clone)]
82pub struct Choreography {
83    /// Name of this choreography (e.g., "filter_update").
84    pub name: String,
85    /// Ordered sequence of steps.
86    steps: Vec<ChoreographyStep>,
87}
88
89impl Choreography {
90    /// Create a new choreography with the given name.
91    pub fn new(name: impl Into<String>) -> Self {
92        Self {
93            name: name.into(),
94            steps: Vec::new(),
95        }
96    }
97
98    /// Add a step to the choreography.
99    pub fn step(&mut self, participant: impl Into<String>, action: Action) -> &mut Self {
100        let seq = self.steps.len();
101        self.steps.push(ChoreographyStep {
102            participant: participant.into(),
103            action,
104            sequence: seq,
105        });
106        self
107    }
108
109    /// Get all steps.
110    pub fn steps(&self) -> &[ChoreographyStep] {
111        &self.steps
112    }
113
114    /// Get all unique participants.
115    pub fn participants(&self) -> BTreeSet<ParticipantId> {
116        self.steps.iter().map(|s| s.participant.clone()).collect()
117    }
118
119    /// Get all message types used.
120    pub fn message_types(&self) -> BTreeSet<MessageType> {
121        let mut types = BTreeSet::new();
122        for step in &self.steps {
123            match &step.action {
124                Action::Emit(msg) => {
125                    types.insert(msg.clone());
126                }
127                Action::Handle(msg, _) => {
128                    types.insert(msg.clone());
129                }
130                Action::Local(_) => {}
131            }
132        }
133        types
134    }
135
136    /// Project the choreography to per-participant handlers.
137    pub fn project(&self) -> Projection {
138        let mut handlers: BTreeMap<ParticipantId, Vec<ProjectedHandler>> = BTreeMap::new();
139
140        for step in &self.steps {
141            let handler = match &step.action {
142                Action::Emit(msg) => ProjectedHandler {
143                    kind: HandlerKind::Emit,
144                    message: msg.clone(),
145                    handler_name: format!("emit_{msg}"),
146                    sequence: step.sequence,
147                },
148                Action::Handle(msg, name) => ProjectedHandler {
149                    kind: HandlerKind::Receive,
150                    message: msg.clone(),
151                    handler_name: name.clone(),
152                    sequence: step.sequence,
153                },
154                Action::Local(name) => ProjectedHandler {
155                    kind: HandlerKind::Local,
156                    message: String::new(),
157                    handler_name: name.clone(),
158                    sequence: step.sequence,
159                },
160            };
161            handlers
162                .entry(step.participant.clone())
163                .or_default()
164                .push(handler);
165        }
166
167        Projection { handlers }
168    }
169
170    /// Verify that the choreography is deadlock-free.
171    ///
172    /// A choreography is deadlock-free if:
173    /// 1. Every emitted message has at least one handler.
174    /// 2. Every handled message has at least one emitter.
175    /// 3. The message dependency graph is acyclic.
176    pub fn verify_deadlock_free(&self) -> bool {
177        let mut emitters: HashMap<&str, Vec<usize>> = HashMap::new();
178        let mut receivers: HashMap<&str, Vec<usize>> = HashMap::new();
179
180        for (i, step) in self.steps.iter().enumerate() {
181            match &step.action {
182                Action::Emit(msg) => emitters.entry(msg.as_str()).or_default().push(i),
183                Action::Handle(msg, _) => receivers.entry(msg.as_str()).or_default().push(i),
184                Action::Local(_) => {}
185            }
186        }
187
188        // Check: every emitted message has at least one receiver.
189        for msg in emitters.keys() {
190            if !receivers.contains_key(msg) {
191                return false;
192            }
193        }
194
195        // Check: every received message has at least one emitter.
196        for msg in receivers.keys() {
197            if !emitters.contains_key(msg) {
198                return false;
199            }
200        }
201
202        // Check: causal ordering is respected (emit before handle in sequence).
203        for (msg, emit_indices) in &emitters {
204            if let Some(recv_indices) = receivers.get(msg) {
205                let min_emit = emit_indices.iter().min().copied().unwrap_or(usize::MAX);
206                let min_recv = recv_indices.iter().min().copied().unwrap_or(0);
207                if min_recv < min_emit {
208                    return false; // Handler runs before any emitter
209                }
210            }
211        }
212
213        // Check: no circular dependencies.
214        // Build a graph: participant A depends on B if A handles a message emitted by B.
215        let mut dep_graph: HashMap<&str, HashSet<&str>> = HashMap::new();
216        for step in &self.steps {
217            if let Action::Handle(msg, _) = &step.action {
218                // Find emitter of this message.
219                for emit_step in &self.steps {
220                    if let Action::Emit(emsg) = &emit_step.action
221                        && emsg == msg
222                    {
223                        dep_graph
224                            .entry(step.participant.as_str())
225                            .or_default()
226                            .insert(emit_step.participant.as_str());
227                    }
228                }
229            }
230        }
231
232        // Cycle detection via BFS/coloring.
233        !has_cycle(&dep_graph)
234    }
235
236    /// Completeness check: are there unhandled messages or unmatched handlers?
237    pub fn completeness_report(&self) -> CompletenessReport {
238        let mut emitted: BTreeSet<String> = BTreeSet::new();
239        let mut handled: BTreeSet<String> = BTreeSet::new();
240
241        for step in &self.steps {
242            match &step.action {
243                Action::Emit(msg) => {
244                    emitted.insert(msg.clone());
245                }
246                Action::Handle(msg, _) => {
247                    handled.insert(msg.clone());
248                }
249                Action::Local(_) => {}
250            }
251        }
252
253        let unhandled: Vec<String> = emitted.difference(&handled).cloned().collect();
254        let orphaned: Vec<String> = handled.difference(&emitted).cloned().collect();
255
256        CompletenessReport {
257            is_complete: unhandled.is_empty() && orphaned.is_empty(),
258            unhandled_messages: unhandled,
259            orphaned_handlers: orphaned,
260            participant_count: self.participants().len(),
261            message_count: self.message_types().len(),
262            step_count: self.steps.len(),
263        }
264    }
265}
266
267/// Check if a directed graph has a cycle.
268fn has_cycle<'a>(graph: &HashMap<&'a str, HashSet<&'a str>>) -> bool {
269    let mut visited = HashSet::new();
270    let mut in_stack = HashSet::new();
271
272    for &node in graph.keys() {
273        if !visited.contains(node) && dfs_cycle(node, graph, &mut visited, &mut in_stack) {
274            return true;
275        }
276    }
277    false
278}
279
280fn dfs_cycle<'a>(
281    node: &'a str,
282    graph: &HashMap<&'a str, HashSet<&'a str>>,
283    visited: &mut HashSet<&'a str>,
284    in_stack: &mut HashSet<&'a str>,
285) -> bool {
286    visited.insert(node);
287    in_stack.insert(node);
288
289    if let Some(neighbors) = graph.get(node) {
290        for &next in neighbors {
291            if !visited.contains(next) {
292                if dfs_cycle(next, graph, visited, in_stack) {
293                    return true;
294                }
295            } else if in_stack.contains(next) {
296                return true;
297            }
298        }
299    }
300
301    in_stack.remove(node);
302    false
303}
304
305/// A projected handler for a single participant.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct ProjectedHandler {
308    /// What kind of handler this is.
309    pub kind: HandlerKind,
310    /// The message type involved (empty for Local).
311    pub message: String,
312    /// The handler function name.
313    pub handler_name: String,
314    /// Original sequence number from the choreography.
315    pub sequence: usize,
316}
317
318/// Kind of projected handler.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum HandlerKind {
321    /// This participant emits the message.
322    Emit,
323    /// This participant receives and handles the message.
324    Receive,
325    /// This participant does local computation.
326    Local,
327}
328
329/// Projected per-participant handlers from a choreography.
330#[derive(Debug, Clone)]
331pub struct Projection {
332    handlers: BTreeMap<ParticipantId, Vec<ProjectedHandler>>,
333}
334
335impl Projection {
336    /// Get handlers for a specific participant.
337    pub fn handlers(&self, participant: &str) -> &[ProjectedHandler] {
338        self.handlers
339            .get(participant)
340            .map(|v| v.as_slice())
341            .unwrap_or(&[])
342    }
343
344    /// Get all participants in the projection.
345    pub fn participants(&self) -> Vec<&str> {
346        self.handlers.keys().map(|s| s.as_str()).collect()
347    }
348
349    /// Check if the projection is complete (all messages are balanced).
350    pub fn is_complete(&self) -> bool {
351        let mut emitted: HashSet<&str> = HashSet::new();
352        let mut received: HashSet<&str> = HashSet::new();
353
354        for handlers in self.handlers.values() {
355            for h in handlers {
356                match h.kind {
357                    HandlerKind::Emit => {
358                        emitted.insert(h.message.as_str());
359                    }
360                    HandlerKind::Receive => {
361                        received.insert(h.message.as_str());
362                    }
363                    HandlerKind::Local => {}
364                }
365            }
366        }
367
368        emitted == received
369    }
370}
371
372/// Report on choreography completeness.
373#[derive(Debug, Clone)]
374pub struct CompletenessReport {
375    /// Whether the choreography is complete.
376    pub is_complete: bool,
377    /// Messages that are emitted but never handled.
378    pub unhandled_messages: Vec<String>,
379    /// Messages that are handled but never emitted.
380    pub orphaned_handlers: Vec<String>,
381    /// Number of participants.
382    pub participant_count: usize,
383    /// Number of distinct message types.
384    pub message_count: usize,
385    /// Total number of choreography steps.
386    pub step_count: usize,
387}
388
389/// Execute a choreography against a set of participant state callbacks.
390///
391/// Returns the ordered list of messages delivered during execution.
392pub fn execute_choreography(choreo: &Choreography) -> Vec<ExecutionEvent> {
393    let mut events = Vec::new();
394    let mut pending_messages: VecDeque<(String, usize)> = VecDeque::new();
395
396    for step in choreo.steps() {
397        match &step.action {
398            Action::Emit(msg) => {
399                pending_messages.push_back((msg.clone(), step.sequence));
400                events.push(ExecutionEvent {
401                    sequence: step.sequence,
402                    participant: step.participant.clone(),
403                    kind: EventKind::Emit(msg.clone()),
404                });
405            }
406            Action::Handle(msg, handler) => {
407                // Check if this message has been emitted.
408                let delivered = pending_messages.iter().any(|(m, _)| m == msg);
409                events.push(ExecutionEvent {
410                    sequence: step.sequence,
411                    participant: step.participant.clone(),
412                    kind: if delivered {
413                        EventKind::Deliver(msg.clone(), handler.clone())
414                    } else {
415                        EventKind::Deadlock(msg.clone())
416                    },
417                });
418            }
419            Action::Local(name) => {
420                events.push(ExecutionEvent {
421                    sequence: step.sequence,
422                    participant: step.participant.clone(),
423                    kind: EventKind::Local(name.clone()),
424                });
425            }
426        }
427    }
428
429    events
430}
431
432/// An event from choreography execution.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct ExecutionEvent {
435    /// Step sequence number.
436    pub sequence: usize,
437    /// Which participant was involved.
438    pub participant: ParticipantId,
439    /// What happened.
440    pub kind: EventKind,
441}
442
443/// Kind of execution event.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub enum EventKind {
446    /// Message was emitted.
447    Emit(String),
448    /// Message was delivered to handler.
449    Deliver(String, String),
450    /// Local computation.
451    Local(String),
452    /// Deadlock: message needed but not yet emitted.
453    Deadlock(String),
454}
455
456// ============================================================================
457// Example Choreographies
458// ============================================================================
459
460/// Filter update: filter dropdown → list + status bar + header.
461pub fn example_filter_update() -> Choreography {
462    let mut c = Choreography::new("filter_update");
463    c.step("filter", Action::Emit("filter_changed".into()));
464    c.step(
465        "list",
466        Action::Handle("filter_changed".into(), "scroll_to_top".into()),
467    );
468    c.step(
469        "status",
470        Action::Handle("filter_changed".into(), "update_count".into()),
471    );
472    c.step(
473        "header",
474        Action::Handle("filter_changed".into(), "highlight_filter".into()),
475    );
476    c
477}
478
479/// Selection sync: list selection → detail panel + status bar.
480pub fn example_selection_sync() -> Choreography {
481    let mut c = Choreography::new("selection_sync");
482    c.step("list", Action::Emit("selection_changed".into()));
483    c.step(
484        "detail",
485        Action::Handle("selection_changed".into(), "show_details".into()),
486    );
487    c.step(
488        "status",
489        Action::Handle("selection_changed".into(), "update_selection_info".into()),
490    );
491    c
492}
493
494/// Tab navigation: tab bar → content panel + breadcrumb + status.
495pub fn example_tab_navigation() -> Choreography {
496    let mut c = Choreography::new("tab_navigation");
497    c.step("tabs", Action::Emit("tab_changed".into()));
498    c.step(
499        "content",
500        Action::Handle("tab_changed".into(), "switch_view".into()),
501    );
502    c.step(
503        "breadcrumb",
504        Action::Handle("tab_changed".into(), "update_path".into()),
505    );
506    c.step(
507        "status",
508        Action::Handle("tab_changed".into(), "update_tab_info".into()),
509    );
510    c
511}
512
513/// Search flow: search input → results list → status + preview.
514pub fn example_search_flow() -> Choreography {
515    let mut c = Choreography::new("search_flow");
516    c.step("search_input", Action::Emit("query_changed".into()));
517    c.step("search_input", Action::Local("debounce".into()));
518    c.step(
519        "results",
520        Action::Handle("query_changed".into(), "filter_results".into()),
521    );
522    c.step("results", Action::Emit("results_updated".into()));
523    c.step(
524        "status",
525        Action::Handle("results_updated".into(), "update_match_count".into()),
526    );
527    c.step(
528        "preview",
529        Action::Handle("results_updated".into(), "update_preview".into()),
530    );
531    c
532}
533
534/// Modal dialog: trigger → overlay + dimmer + focus trap.
535pub fn example_modal_dialog() -> Choreography {
536    let mut c = Choreography::new("modal_dialog");
537    c.step("trigger", Action::Emit("open_modal".into()));
538    c.step(
539        "overlay",
540        Action::Handle("open_modal".into(), "show_overlay".into()),
541    );
542    c.step(
543        "dimmer",
544        Action::Handle("open_modal".into(), "dim_background".into()),
545    );
546    c.step(
547        "focus_trap",
548        Action::Handle("open_modal".into(), "capture_focus".into()),
549    );
550    c
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn filter_update_is_deadlock_free() {
559        let c = example_filter_update();
560        assert!(c.verify_deadlock_free());
561    }
562
563    #[test]
564    fn filter_update_projection() {
565        let c = example_filter_update();
566        let p = c.project();
567        assert_eq!(p.participants().len(), 4);
568        assert_eq!(p.handlers("filter").len(), 1);
569        assert_eq!(p.handlers("list").len(), 1);
570        assert_eq!(p.handlers("status").len(), 1);
571        assert_eq!(p.handlers("header").len(), 1);
572        assert!(p.is_complete());
573    }
574
575    #[test]
576    fn filter_update_completeness() {
577        let c = example_filter_update();
578        let report = c.completeness_report();
579        assert!(report.is_complete);
580        assert_eq!(report.participant_count, 4);
581        assert_eq!(report.message_count, 1);
582        assert_eq!(report.step_count, 4);
583    }
584
585    #[test]
586    fn selection_sync_works() {
587        let c = example_selection_sync();
588        assert!(c.verify_deadlock_free());
589        let p = c.project();
590        assert!(p.is_complete());
591        assert_eq!(p.participants().len(), 3);
592    }
593
594    #[test]
595    fn tab_navigation_works() {
596        let c = example_tab_navigation();
597        assert!(c.verify_deadlock_free());
598        assert!(c.project().is_complete());
599    }
600
601    #[test]
602    fn search_flow_chained_messages() {
603        let c = example_search_flow();
604        assert!(c.verify_deadlock_free());
605        let p = c.project();
606        assert!(p.is_complete());
607        assert_eq!(c.message_types().len(), 2); // query_changed + results_updated
608    }
609
610    #[test]
611    fn modal_dialog_works() {
612        let c = example_modal_dialog();
613        assert!(c.verify_deadlock_free());
614        assert!(c.project().is_complete());
615    }
616
617    #[test]
618    fn orphaned_handler_detected() {
619        let mut c = Choreography::new("broken");
620        c.step(
621            "widget_a",
622            Action::Handle("nonexistent".into(), "do_stuff".into()),
623        );
624        let report = c.completeness_report();
625        assert!(!report.is_complete);
626        assert_eq!(report.orphaned_handlers, vec!["nonexistent"]);
627    }
628
629    #[test]
630    fn unhandled_message_detected() {
631        let mut c = Choreography::new("incomplete");
632        c.step("widget_a", Action::Emit("fire_and_forget".into()));
633        let report = c.completeness_report();
634        assert!(!report.is_complete);
635        assert_eq!(report.unhandled_messages, vec!["fire_and_forget"]);
636    }
637
638    #[test]
639    fn deadlock_from_missing_emitter() {
640        let mut c = Choreography::new("deadlock");
641        c.step(
642            "widget_a",
643            Action::Handle("missing".into(), "handler".into()),
644        );
645        assert!(!c.verify_deadlock_free());
646    }
647
648    #[test]
649    fn deadlock_from_handle_before_emit() {
650        let mut c = Choreography::new("out_of_order");
651        c.step("widget_b", Action::Handle("msg".into(), "process".into()));
652        c.step("widget_a", Action::Emit("msg".into()));
653        assert!(!c.verify_deadlock_free());
654    }
655
656    #[test]
657    fn circular_dependency_detected() {
658        let mut c = Choreography::new("circular");
659        c.step("a", Action::Emit("msg_ab".into()));
660        c.step("b", Action::Handle("msg_ab".into(), "handle_ab".into()));
661        c.step("b", Action::Emit("msg_ba".into()));
662        c.step("a", Action::Handle("msg_ba".into(), "handle_ba".into()));
663        assert!(!c.verify_deadlock_free());
664    }
665
666    #[test]
667    fn execute_filter_update() {
668        let c = example_filter_update();
669        let events = execute_choreography(&c);
670        assert_eq!(events.len(), 4);
671        assert!(matches!(&events[0].kind, EventKind::Emit(m) if m == "filter_changed"));
672        assert!(matches!(&events[1].kind, EventKind::Deliver(m, _) if m == "filter_changed"));
673        assert!(matches!(&events[2].kind, EventKind::Deliver(m, _) if m == "filter_changed"));
674        assert!(matches!(&events[3].kind, EventKind::Deliver(m, _) if m == "filter_changed"));
675    }
676
677    #[test]
678    fn execute_detects_deadlock_event() {
679        let mut c = Choreography::new("deadlock_exec");
680        c.step(
681            "widget_b",
682            Action::Handle("missing".into(), "handler".into()),
683        );
684        let events = execute_choreography(&c);
685        assert_eq!(events.len(), 1);
686        assert!(matches!(&events[0].kind, EventKind::Deadlock(m) if m == "missing"));
687    }
688
689    #[test]
690    fn empty_choreography() {
691        let c = Choreography::new("empty");
692        assert!(c.verify_deadlock_free());
693        assert!(c.project().is_complete());
694        let report = c.completeness_report();
695        assert!(report.is_complete);
696        assert_eq!(report.step_count, 0);
697    }
698
699    #[test]
700    fn local_actions_preserved() {
701        let mut c = Choreography::new("with_local");
702        c.step("widget", Action::Local("compute".into()));
703        let p = c.project();
704        assert_eq!(p.handlers("widget").len(), 1);
705        assert_eq!(p.handlers("widget")[0].kind, HandlerKind::Local);
706    }
707
708    #[test]
709    fn choreography_determinism() {
710        let c1 = example_search_flow();
711        let c2 = example_search_flow();
712        let p1 = c1.project();
713        let p2 = c2.project();
714        assert_eq!(p1.participants(), p2.participants());
715        for part in p1.participants() {
716            assert_eq!(p1.handlers(part), p2.handlers(part));
717        }
718    }
719
720    #[test]
721    fn comparison_manual_vs_choreographic() {
722        // Manual approach: 4 separate message routes
723        // Choreographic approach: 1 choreography, 4 projected handlers
724        let c = example_filter_update();
725        let p = c.project();
726
727        // The choreographic version produces the same handlers
728        // but guarantees completeness
729        assert_eq!(p.handlers("filter")[0].kind, HandlerKind::Emit);
730        assert_eq!(p.handlers("list")[0].kind, HandlerKind::Receive);
731        assert_eq!(p.handlers("status")[0].kind, HandlerKind::Receive);
732        assert_eq!(p.handlers("header")[0].kind, HandlerKind::Receive);
733
734        // Completeness is verified
735        assert!(c.completeness_report().is_complete);
736    }
737}