Skip to main content

fret_runtime/
command_dispatch_diagnostics.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use fret_core::{AppWindowId, FrameId};
5
6use crate::{CommandId, CommandScope, TickId};
7
8/// Best-effort classification of where a command dispatch originated.
9///
10/// This is diagnostics-only metadata intended to improve explainability in `fretboard diag`.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum CommandDispatchSourceKindV1 {
13    Pointer,
14    Keyboard,
15    Shortcut,
16    Programmatic,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CommandDispatchSourceV1 {
21    pub kind: CommandDispatchSourceKindV1,
22    /// `GlobalElementId.0` (from `crates/fret-ui`) when available.
23    pub element: Option<u64>,
24    /// Best-effort stable selector for explainability (typically a semantics `test_id`).
25    ///
26    /// This is diagnostics-only metadata intended to make pointer-triggered `Effect::Command`
27    /// dispatch explainable without requiring callers to correlate element IDs with a semantics
28    /// snapshot.
29    pub test_id: Option<Arc<str>>,
30}
31
32impl CommandDispatchSourceV1 {
33    pub fn programmatic() -> Self {
34        Self {
35            kind: CommandDispatchSourceKindV1::Programmatic,
36            element: None,
37            test_id: None,
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CommandDispatchDecisionV1 {
44    pub seq: u64,
45    pub frame_id: FrameId,
46    pub tick_id: TickId,
47    pub window: AppWindowId,
48    pub command: CommandId,
49    pub source: CommandDispatchSourceV1,
50    pub handled: bool,
51    /// `GlobalElementId.0` (from `crates/fret-ui`) for the first widget that handled the command.
52    pub handled_by_element: Option<u64>,
53    /// Best-effort handler scope classification for explainability (ADR 0307).
54    ///
55    /// Notes:
56    /// - `Some(CommandScope::Widget)` means the command was handled by bubbling widget dispatch.
57    /// - For driver-handled commands, this is typically `Some(CommandScope::Window)` or
58    ///   `Some(CommandScope::App)`.
59    /// - `None` means the command was not handled (or the scope could not be determined).
60    pub handled_by_scope: Option<CommandScope>,
61    /// Whether the command was handled by a runner/driver integration layer (not by a UI element).
62    pub handled_by_driver: bool,
63    pub stopped: bool,
64    pub started_from_focus: bool,
65    pub used_default_root_fallback: bool,
66}
67
68#[derive(Default)]
69pub struct WindowCommandDispatchDiagnosticsStore {
70    next_seq: u64,
71    per_window: HashMap<AppWindowId, Vec<CommandDispatchDecisionV1>>,
72}
73
74impl WindowCommandDispatchDiagnosticsStore {
75    const MAX_ENTRIES_PER_WINDOW: usize = 128;
76
77    pub fn record(&mut self, mut decision: CommandDispatchDecisionV1) {
78        decision.seq = self.next_seq;
79        self.next_seq = self.next_seq.saturating_add(1);
80
81        let entries = self.per_window.entry(decision.window).or_default();
82        entries.push(decision);
83        if entries.len() > Self::MAX_ENTRIES_PER_WINDOW {
84            let extra = entries.len().saturating_sub(Self::MAX_ENTRIES_PER_WINDOW);
85            entries.drain(0..extra);
86        }
87    }
88
89    pub fn decisions_for_frame(
90        &self,
91        window: AppWindowId,
92        frame_id: FrameId,
93        max_entries: usize,
94    ) -> Vec<CommandDispatchDecisionV1> {
95        let Some(entries) = self.per_window.get(&window) else {
96            return Vec::new();
97        };
98        entries
99            .iter()
100            .rev()
101            .filter(|e| e.frame_id == frame_id)
102            .take(max_entries)
103            .cloned()
104            .collect::<Vec<_>>()
105            .into_iter()
106            .rev()
107            .collect()
108    }
109
110    pub fn snapshot_since(
111        &self,
112        window: AppWindowId,
113        since_seq: u64,
114        max_entries: usize,
115    ) -> Vec<CommandDispatchDecisionV1> {
116        let Some(entries) = self.per_window.get(&window) else {
117            return Vec::new();
118        };
119        entries
120            .iter()
121            .filter(|e| e.seq >= since_seq)
122            .take(max_entries)
123            .cloned()
124            .collect()
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129struct PendingCommandDispatchSourceV1 {
130    tick_id: TickId,
131    window: AppWindowId,
132    command: CommandId,
133    source: CommandDispatchSourceV1,
134}
135
136/// Frame/tick-local source metadata for the next `Effect::Command` dispatch.
137///
138/// This is a diagnostics-only escape hatch so pointer-triggered dispatch (which is encoded via an
139/// `Effect::Command`) can still be explained as “element X dispatched command Y”.
140#[derive(Default)]
141pub struct WindowPendingCommandDispatchSourceService {
142    per_window: HashMap<AppWindowId, Vec<PendingCommandDispatchSourceV1>>,
143}
144
145impl WindowPendingCommandDispatchSourceService {
146    const MAX_PENDING_PER_WINDOW: usize = 32;
147    const PENDING_SOURCE_TTL_TICKS: u64 = 64;
148
149    pub fn record(
150        &mut self,
151        window: AppWindowId,
152        tick_id: TickId,
153        command: CommandId,
154        source: CommandDispatchSourceV1,
155    ) {
156        let pending = PendingCommandDispatchSourceV1 {
157            tick_id,
158            window,
159            command,
160            source,
161        };
162        let entries = self.per_window.entry(window).or_default();
163        entries.push(pending);
164        if entries.len() > Self::MAX_PENDING_PER_WINDOW {
165            let extra = entries.len().saturating_sub(Self::MAX_PENDING_PER_WINDOW);
166            entries.drain(0..extra);
167        }
168    }
169
170    pub fn consume(
171        &mut self,
172        window: AppWindowId,
173        tick_id: TickId,
174        command: &CommandId,
175    ) -> Option<CommandDispatchSourceV1> {
176        let entries = self.per_window.get_mut(&window)?;
177
178        // Drop stale pending entries.
179        //
180        // This metadata is best-effort and diagnostics-only: in practice, effect-driven command
181        // dispatch can be handled on a later tick (e.g. when the platform/backend defers effect
182        // flushing, or when a UI interaction schedules work for a subsequent frame).
183        //
184        // Keep a small TTL window so pointer/keyboard-triggered dispatch remains explainable in
185        // `fretboard diag` without changing the `Effect::Command` schema.
186        let min_tick = TickId(tick_id.0.saturating_sub(Self::PENDING_SOURCE_TTL_TICKS));
187        entries.retain(|e| e.tick_id.0 >= min_tick.0 && e.tick_id.0 <= tick_id.0);
188
189        let pos = entries
190            .iter()
191            .rposition(|e| &e.command == command && e.window == window)?;
192        Some(entries.remove(pos).source)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn pending_source_expires_across_ticks() {
202        let mut svc = WindowPendingCommandDispatchSourceService::default();
203        let window = AppWindowId::default();
204        let cmd = CommandId::from("test.cmd");
205
206        svc.record(
207            window,
208            TickId(10),
209            cmd.clone(),
210            CommandDispatchSourceV1 {
211                kind: CommandDispatchSourceKindV1::Pointer,
212                element: Some(42),
213                test_id: None,
214            },
215        );
216
217        assert_eq!(
218            svc.consume(window, TickId(10), &cmd),
219            Some(CommandDispatchSourceV1 {
220                kind: CommandDispatchSourceKindV1::Pointer,
221                element: Some(42),
222                test_id: None,
223            })
224        );
225
226        svc.record(
227            window,
228            TickId(10),
229            cmd.clone(),
230            CommandDispatchSourceV1 {
231                kind: CommandDispatchSourceKindV1::Pointer,
232                element: Some(42),
233                test_id: None,
234            },
235        );
236
237        assert_eq!(
238            svc.consume(window, TickId(11), &cmd),
239            Some(CommandDispatchSourceV1 {
240                kind: CommandDispatchSourceKindV1::Pointer,
241                element: Some(42),
242                test_id: None,
243            })
244        );
245
246        svc.record(
247            window,
248            TickId(10),
249            cmd.clone(),
250            CommandDispatchSourceV1 {
251                kind: CommandDispatchSourceKindV1::Pointer,
252                element: Some(42),
253                test_id: None,
254            },
255        );
256
257        assert_eq!(svc.consume(window, TickId(80), &cmd), None);
258    }
259
260    #[test]
261    fn pending_source_prefers_most_recent_match() {
262        let mut svc = WindowPendingCommandDispatchSourceService::default();
263        let window = AppWindowId::default();
264        let cmd = CommandId::from("test.cmd");
265
266        svc.record(
267            window,
268            TickId(10),
269            cmd.clone(),
270            CommandDispatchSourceV1 {
271                kind: CommandDispatchSourceKindV1::Pointer,
272                element: Some(1),
273                test_id: None,
274            },
275        );
276        svc.record(
277            window,
278            TickId(12),
279            cmd.clone(),
280            CommandDispatchSourceV1 {
281                kind: CommandDispatchSourceKindV1::Pointer,
282                element: Some(2),
283                test_id: None,
284            },
285        );
286
287        assert_eq!(
288            svc.consume(window, TickId(20), &cmd),
289            Some(CommandDispatchSourceV1 {
290                kind: CommandDispatchSourceKindV1::Pointer,
291                element: Some(2),
292                test_id: None,
293            })
294        );
295    }
296}