Skip to main content

dbgflow_core/
lib.rs

1//! Core runtime for the `dbgflow` graph debugger.
2//!
3//! This crate contains the in-memory session model, event collector, session
4//! persistence helpers, and the embedded local UI server.
5#![warn(missing_docs)]
6
7use std::any::type_name_of_val;
8use std::cell::RefCell;
9use std::collections::{BTreeMap, BTreeSet};
10use std::env;
11use std::fmt::Debug;
12use std::fs;
13use std::io::{Read, Write};
14use std::net::{TcpListener, TcpStream};
15use std::path::{Path, PathBuf};
16use std::process;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Mutex, MutexGuard, OnceLock};
19
20use serde::{Deserialize, Serialize};
21
22/// Graph node metadata persisted in a debugging session.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct Node {
25    /// Stable node identifier within a session.
26    pub id: String,
27    /// Human-readable label shown in the UI.
28    pub label: String,
29    /// Semantic node type.
30    pub kind: NodeKind,
31    /// Rust module path where the node originated.
32    pub module_path: String,
33    /// Source file path captured for the node.
34    pub file: String,
35    /// Source line captured for the node.
36    pub line: u32,
37}
38
39/// Supported node types in the session graph.
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum NodeKind {
43    /// A traced function node.
44    Function,
45    /// A `#[ui_debug]` data node.
46    Type,
47    /// A test node emitted by `#[dbg_test]` or test helpers.
48    Test,
49}
50
51/// Directed edge between two graph nodes.
52#[derive(Clone, Debug, Serialize, Deserialize)]
53pub struct Edge {
54    /// Parent or source node identifier.
55    pub from: String,
56    /// Child or target node identifier.
57    pub to: String,
58}
59
60/// Named value preview attached to an event.
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct ValueSlot {
63    /// Logical field name or label.
64    pub name: String,
65    /// String preview rendered by the UI.
66    pub preview: String,
67}
68
69/// Single recorded execution event.
70#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct Event {
72    /// Monotonic sequence number within the session.
73    pub seq: u64,
74    /// Call identifier when the event belongs to a traced function invocation.
75    pub call_id: Option<u64>,
76    /// Parent call identifier when nested under another traced invocation.
77    pub parent_call_id: Option<u64>,
78    /// Node identifier this event belongs to.
79    pub node_id: String,
80    /// Event category.
81    pub kind: EventKind,
82    /// Short title shown in the UI timeline.
83    pub title: String,
84    /// Attached value previews.
85    pub values: Vec<ValueSlot>,
86}
87
88/// Supported event kinds emitted by the runtime.
89#[derive(Clone, Debug, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum EventKind {
92    /// Function entry event.
93    FunctionEnter,
94    /// Function exit or unwind event.
95    FunctionExit,
96    /// Snapshot of a `#[ui_debug]` value.
97    ValueSnapshot,
98    /// Test start event.
99    TestStarted,
100    /// Test success event.
101    TestPassed,
102    /// Test failure event.
103    TestFailed,
104}
105
106/// Complete replayable debugging session.
107#[derive(Clone, Debug, Serialize, Deserialize)]
108pub struct Session {
109    /// Session title shown in the UI.
110    pub title: String,
111    /// All discovered graph nodes.
112    pub nodes: Vec<Node>,
113    /// All graph edges.
114    pub edges: Vec<Edge>,
115    /// Ordered execution events.
116    pub events: Vec<Event>,
117}
118
119impl Session {
120    /// Creates an empty session with the given title.
121    pub fn new(title: impl Into<String>) -> Self {
122        Self {
123            title: title.into(),
124            nodes: Vec::new(),
125            edges: Vec::new(),
126            events: Vec::new(),
127        }
128    }
129}
130
131/// Static metadata generated for traced functions.
132#[derive(Clone, Copy, Debug)]
133pub struct FunctionMeta {
134    /// Stable function identifier.
135    pub id: &'static str,
136    /// Human-readable label.
137    pub label: &'static str,
138    /// Rust module path.
139    pub module_path: &'static str,
140    /// Source file.
141    pub file: &'static str,
142    /// Source line.
143    pub line: u32,
144}
145
146/// Static metadata generated for `#[ui_debug]` types.
147#[derive(Clone, Copy, Debug)]
148pub struct TypeMeta {
149    /// Stable type identifier.
150    pub id: &'static str,
151    /// Human-readable label.
152    pub label: &'static str,
153    /// Rust module path.
154    pub module_path: &'static str,
155    /// Source file.
156    pub file: &'static str,
157    /// Source line.
158    pub line: u32,
159}
160
161#[derive(Clone, Debug)]
162struct CallFrame {
163    call_id: u64,
164    node_id: String,
165}
166
167#[derive(Default)]
168struct RuntimeState {
169    title: String,
170    nodes: BTreeMap<String, Node>,
171    edges: BTreeSet<(String, String)>,
172    events: Vec<Event>,
173    last_event_node_id: Option<String>,
174    next_seq: AtomicU64,
175    next_call_id: AtomicU64,
176}
177
178thread_local! {
179    static CALL_STACK: RefCell<Vec<CallFrame>> = const { RefCell::new(Vec::new()) };
180}
181
182static STATE: OnceLock<Mutex<RuntimeState>> = OnceLock::new();
183
184fn state() -> &'static Mutex<RuntimeState> {
185    STATE.get_or_init(|| Mutex::new(RuntimeState::default()))
186}
187
188fn lock_state() -> MutexGuard<'static, RuntimeState> {
189    state().lock().expect("dbgflow-core runtime mutex poisoned")
190}
191
192fn next_seq(state: &RuntimeState) -> u64 {
193    state.next_seq.fetch_add(1, Ordering::Relaxed) + 1
194}
195
196fn next_call_id(state: &RuntimeState) -> u64 {
197    state.next_call_id.fetch_add(1, Ordering::Relaxed) + 1
198}
199
200fn push_event(state: &mut RuntimeState, mut event: Event) {
201    event.seq = next_seq(state);
202    state.last_event_node_id = Some(event.node_id.clone());
203    state.events.push(event);
204}
205
206fn ensure_node(state: &mut RuntimeState, node: Node) {
207    state.nodes.entry(node.id.clone()).or_insert(node);
208}
209
210fn ensure_edge(state: &mut RuntimeState, from: &str, to: &str) {
211    if from == to {
212        return;
213    }
214
215    state.edges.insert((from.to_owned(), to.to_owned()));
216}
217
218/// Clears the runtime and starts a new in-memory session.
219pub fn reset_session(title: impl Into<String>) {
220    let mut state = lock_state();
221    state.title = title.into();
222    state.nodes.clear();
223    state.edges.clear();
224    state.events.clear();
225    state.last_event_node_id = None;
226    state.next_seq.store(0, Ordering::Relaxed);
227    state.next_call_id.store(0, Ordering::Relaxed);
228    CALL_STACK.with(|stack| stack.borrow_mut().clear());
229}
230
231/// Returns a snapshot of the current in-memory session.
232pub fn current_session() -> Session {
233    let state = lock_state();
234    let nodes = state.nodes.values().cloned().collect();
235    let edges = state
236        .edges
237        .iter()
238        .map(|(from, to)| Edge {
239            from: from.clone(),
240            to: to.clone(),
241        })
242        .collect();
243
244    Session {
245        title: state.title.clone(),
246        nodes,
247        edges,
248        events: state.events.clone(),
249    }
250}
251
252/// Writes the current session to a JSON file.
253pub fn write_session_json(path: impl AsRef<Path>) -> std::io::Result<()> {
254    let session = current_session();
255    let json = serde_json::to_string_pretty(&session).map_err(std::io::Error::other)?;
256
257    if let Some(parent) = path.as_ref().parent() {
258        fs::create_dir_all(parent)?;
259    }
260
261    fs::write(path, json)
262}
263
264/// Reads a session from a JSON file.
265pub fn read_session_json(path: impl AsRef<Path>) -> std::io::Result<Session> {
266    let content = fs::read_to_string(path)?;
267    let session = serde_json::from_str(&content).map_err(std::io::Error::other)?;
268    Ok(session)
269}
270
271fn sanitize_filename(label: &str) -> String {
272    let sanitized: String = label
273        .chars()
274        .map(|ch| match ch {
275            'a'..='z' | 'A'..='Z' | '0'..='9' => ch,
276            _ => '-',
277        })
278        .collect();
279
280    sanitized.trim_matches('-').to_owned()
281}
282
283/// Writes the current session into a directory using a sanitized file name.
284pub fn write_session_snapshot_in_dir(
285    dir: impl AsRef<Path>,
286    label: impl AsRef<str>,
287) -> std::io::Result<PathBuf> {
288    fs::create_dir_all(&dir)?;
289
290    let file_name = format!(
291        "{}-{}.json",
292        sanitize_filename(label.as_ref()),
293        process::id()
294    );
295    let path = dir.as_ref().join(file_name);
296    write_session_json(&path)?;
297    Ok(path)
298}
299
300/// Writes the current session into the directory pointed to by `DBG_SESSION_DIR`.
301pub fn write_session_snapshot_from_env(label: impl AsRef<str>) -> std::io::Result<Option<PathBuf>> {
302    match env::var("DBG_SESSION_DIR") {
303        Ok(dir) => write_session_snapshot_in_dir(dir, label).map(Some),
304        Err(env::VarError::NotPresent) => Ok(None),
305        Err(error) => Err(std::io::Error::other(error)),
306    }
307}
308
309/// Produces a cheap type-oriented preview for values captured by trace arguments.
310pub fn type_preview<T>(value: &T) -> String {
311    format!("type {}", type_name_of_val(value))
312}
313
314/// Trait implemented for values that should appear as UI data nodes.
315pub trait UiDebugValue: Debug + Sized {
316    /// Returns the static metadata for the type.
317    fn ui_debug_type_meta() -> TypeMeta;
318
319    /// Returns the text snapshot shown in the UI.
320    fn ui_debug_snapshot(&self) -> String {
321        format!("{self:#?}")
322    }
323
324    /// Emits a value snapshot event for the current session.
325    fn emit_snapshot(&self, label: impl Into<String>) {
326        runtime::record_type_snapshot(self, label.into());
327    }
328}
329
330/// Runtime helpers used by generated macros and advanced callers.
331pub mod runtime {
332    use super::{
333        CALL_STACK, CallFrame, Event, EventKind, FunctionMeta, Node, NodeKind, UiDebugValue,
334        ValueSlot, ensure_edge, ensure_node, lock_state, next_call_id, push_event,
335    };
336
337    /// Guard object representing an active traced function invocation.
338    #[derive(Debug)]
339    pub struct TraceFrame {
340        call_id: u64,
341        parent_call_id: Option<u64>,
342        node_id: &'static str,
343        finished: bool,
344    }
345
346    impl TraceFrame {
347        /// Enters a traced function and records the corresponding event.
348        pub fn enter(meta: FunctionMeta, values: Vec<ValueSlot>) -> Self {
349            let (call_id, parent_call_id) = {
350                let mut state = lock_state();
351
352                ensure_node(
353                    &mut state,
354                    Node {
355                        id: meta.id.to_owned(),
356                        label: meta.label.to_owned(),
357                        kind: NodeKind::Function,
358                        module_path: meta.module_path.to_owned(),
359                        file: meta.file.to_owned(),
360                        line: meta.line,
361                    },
362                );
363
364                let call_id = next_call_id(&state);
365                let parent_call_id = CALL_STACK.with(|stack| {
366                    let stack = stack.borrow();
367                    stack.last().map(|frame| frame.call_id)
368                });
369
370                let parent_node = CALL_STACK.with(|stack| {
371                    let stack = stack.borrow();
372                    stack.last().map(|frame| frame.node_id.clone())
373                });
374
375                if let Some(parent_node) = parent_node {
376                    ensure_edge(&mut state, &parent_node, meta.id);
377                }
378
379                push_event(
380                    &mut state,
381                    Event {
382                        seq: 0,
383                        call_id: Some(call_id),
384                        parent_call_id,
385                        node_id: meta.id.to_owned(),
386                        kind: EventKind::FunctionEnter,
387                        title: format!("enter {}", meta.label),
388                        values,
389                    },
390                );
391
392                (call_id, parent_call_id)
393            };
394
395            CALL_STACK.with(|stack| {
396                stack.borrow_mut().push(CallFrame {
397                    call_id,
398                    node_id: meta.id.to_owned(),
399                });
400            });
401
402            Self {
403                call_id,
404                parent_call_id,
405                node_id: meta.id,
406                finished: false,
407            }
408        }
409
410        /// Records a successful function return.
411        pub fn finish_return<T>(&mut self, result: &T) {
412            if self.finished {
413                return;
414            }
415
416            {
417                let mut state = lock_state();
418                push_event(
419                    &mut state,
420                    Event {
421                        seq: 0,
422                        call_id: Some(self.call_id),
423                        parent_call_id: self.parent_call_id,
424                        node_id: self.node_id.to_owned(),
425                        kind: EventKind::FunctionExit,
426                        title: format!(
427                            "return {}",
428                            self.node_id.rsplit("::").next().unwrap_or(self.node_id)
429                        ),
430                        values: vec![ValueSlot {
431                            name: "result".to_owned(),
432                            preview: super::type_preview(result),
433                        }],
434                    },
435                );
436            }
437
438            self.finished = true;
439            pop_stack(self.call_id);
440        }
441
442        /// Records an error or unwind outcome for the current frame.
443        pub fn finish_error(&mut self, message: impl Into<String>) {
444            if self.finished {
445                return;
446            }
447
448            {
449                let mut state = lock_state();
450                push_event(
451                    &mut state,
452                    Event {
453                        seq: 0,
454                        call_id: Some(self.call_id),
455                        parent_call_id: self.parent_call_id,
456                        node_id: self.node_id.to_owned(),
457                        kind: EventKind::FunctionExit,
458                        title: format!(
459                            "panic {}",
460                            self.node_id.rsplit("::").next().unwrap_or(self.node_id)
461                        ),
462                        values: vec![ValueSlot {
463                            name: "status".to_owned(),
464                            preview: message.into(),
465                        }],
466                    },
467                );
468            }
469
470            self.finished = true;
471            pop_stack(self.call_id);
472        }
473    }
474
475    impl Drop for TraceFrame {
476        fn drop(&mut self) {
477            if self.finished {
478                return;
479            }
480
481            self.finish_error("unwound before explicit return");
482        }
483    }
484
485    fn pop_stack(call_id: u64) {
486        CALL_STACK.with(|stack| {
487            let mut stack = stack.borrow_mut();
488            if stack.last().map(|frame| frame.call_id) == Some(call_id) {
489                stack.pop();
490            }
491        });
492    }
493
494    /// Builds a value preview for a traced function argument.
495    pub fn preview_argument<T>(name: impl Into<String>, value: &T) -> ValueSlot {
496        ValueSlot {
497            name: name.into(),
498            preview: super::type_preview(value),
499        }
500    }
501
502    /// Records a snapshot for a `#[ui_debug]` value.
503    pub fn record_type_snapshot<T: UiDebugValue>(value: &T, label: impl Into<String>) {
504        let meta = T::ui_debug_type_meta();
505        let mut state = lock_state();
506
507        ensure_node(
508            &mut state,
509            Node {
510                id: meta.id.to_owned(),
511                label: meta.label.to_owned(),
512                kind: NodeKind::Type,
513                module_path: meta.module_path.to_owned(),
514                file: meta.file.to_owned(),
515                line: meta.line,
516            },
517        );
518
519        if let Some(parent_node) = CALL_STACK.with(|stack| {
520            let stack = stack.borrow();
521            stack.last().map(|frame| frame.node_id.clone())
522        }) {
523            ensure_edge(&mut state, &parent_node, meta.id);
524        }
525
526        push_event(
527            &mut state,
528            Event {
529                seq: 0,
530                call_id: CALL_STACK.with(|stack| {
531                    let stack = stack.borrow();
532                    stack.last().map(|frame| frame.call_id)
533                }),
534                parent_call_id: None,
535                node_id: meta.id.to_owned(),
536                kind: EventKind::ValueSnapshot,
537                title: label.into(),
538                values: vec![ValueSlot {
539                    name: meta.label.to_owned(),
540                    preview: value.ui_debug_snapshot(),
541                }],
542            },
543        );
544    }
545
546    /// Records that a test started and explicitly links it to a node.
547    pub fn record_test_started(test_name: impl Into<String>, node_id: impl Into<String>) {
548        record_test_event(
549            EventKind::TestStarted,
550            test_name.into(),
551            node_id.into(),
552            Vec::new(),
553        );
554    }
555
556    /// Records that a test passed and explicitly links it to a node.
557    pub fn record_test_passed(test_name: impl Into<String>, node_id: impl Into<String>) {
558        record_test_event(
559            EventKind::TestPassed,
560            test_name.into(),
561            node_id.into(),
562            Vec::new(),
563        );
564    }
565
566    /// Records that a test failed and explicitly links it to a node.
567    pub fn record_test_failed(
568        test_name: impl Into<String>,
569        node_id: impl Into<String>,
570        failure: impl Into<String>,
571    ) {
572        record_test_event(
573            EventKind::TestFailed,
574            test_name.into(),
575            node_id.into(),
576            vec![ValueSlot {
577                name: "failure".to_owned(),
578                preview: failure.into(),
579            }],
580        );
581    }
582
583    /// Returns the node id attached to the most recently recorded event.
584    pub fn latest_node_id() -> Option<String> {
585        let state = lock_state();
586        state.last_event_node_id.clone()
587    }
588
589    /// Records that a test started and links it to the latest active node.
590    pub fn record_test_started_latest(test_name: impl Into<String>) {
591        let test_name = test_name.into();
592        let node_id = latest_node_id().unwrap_or_else(|| format!("test::{test_name}"));
593        record_test_event(EventKind::TestStarted, test_name, node_id, Vec::new());
594    }
595
596    /// Records that a test passed and links it to the latest active node.
597    pub fn record_test_passed_latest(test_name: impl Into<String>) {
598        let test_name = test_name.into();
599        let node_id = latest_node_id().unwrap_or_else(|| format!("test::{test_name}"));
600        record_test_event(EventKind::TestPassed, test_name, node_id, Vec::new());
601    }
602
603    /// Records that a test failed and links it to the latest active node.
604    pub fn record_test_failed_latest(test_name: impl Into<String>, failure: impl Into<String>) {
605        let test_name = test_name.into();
606        let node_id = latest_node_id().unwrap_or_else(|| format!("test::{test_name}"));
607        record_test_event(
608            EventKind::TestFailed,
609            test_name,
610            node_id,
611            vec![ValueSlot {
612                name: "failure".to_owned(),
613                preview: failure.into(),
614            }],
615        );
616    }
617
618    fn record_test_event(
619        kind: EventKind,
620        test_name: String,
621        node_id: String,
622        values: Vec<ValueSlot>,
623    ) {
624        let test_id = format!("test::{test_name}");
625        let mut state = lock_state();
626
627        ensure_node(
628            &mut state,
629            Node {
630                id: test_id.clone(),
631                label: test_name.clone(),
632                kind: NodeKind::Test,
633                module_path: "cargo::test".to_owned(),
634                file: "<runner>".to_owned(),
635                line: 0,
636            },
637        );
638        ensure_edge(&mut state, &test_id, &node_id);
639
640        push_event(
641            &mut state,
642            Event {
643                seq: 0,
644                call_id: None,
645                parent_call_id: None,
646                node_id: test_id,
647                kind,
648                title: test_name,
649                values,
650            },
651        );
652    }
653}
654
655fn content_type(path: &str) -> &'static str {
656    match path {
657        "/" => "text/html; charset=utf-8",
658        "/app.js" => "application/javascript; charset=utf-8",
659        "/app.css" => "text/css; charset=utf-8",
660        "/session.json" => "application/json; charset=utf-8",
661        _ => "text/plain; charset=utf-8",
662    }
663}
664
665fn write_response(
666    stream: &mut TcpStream,
667    method: &str,
668    status: &str,
669    content_type: &str,
670    body: &str,
671) -> std::io::Result<()> {
672    let response = if method == "HEAD" {
673        format!(
674            "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
675            body.len()
676        )
677    } else {
678        format!(
679            "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
680            body.len()
681        )
682    };
683    stream.write_all(response.as_bytes())
684}
685
686fn read_request(stream: &mut TcpStream) -> std::io::Result<(String, String)> {
687    let mut buffer = [0_u8; 4096];
688    let bytes_read = stream.read(&mut buffer)?;
689    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
690    let line = request.lines().next().unwrap_or_default();
691    let mut parts = line.split_whitespace();
692    let method = parts.next().unwrap_or("GET");
693    let path = parts.next().unwrap_or("/");
694    Ok((method.to_owned(), path.to_owned()))
695}
696
697/// Serves a session over the embedded local HTTP server.
698pub fn serve_session(session: Session, host: &str, port: u16) -> std::io::Result<()> {
699    let listener = TcpListener::bind((host, port))?;
700    let json = serde_json::to_string(&session).map_err(std::io::Error::other)?;
701    let html = ui::index_html();
702    let app_js = ui::app_js();
703    let app_css = ui::app_css();
704
705    println!("Debugger UI: http://{host}:{port}");
706
707    for stream in listener.incoming() {
708        let mut stream = match stream {
709            Ok(stream) => stream,
710            Err(_) => continue,
711        };
712
713        let (method, path) = match read_request(&mut stream) {
714            Ok(request) => request,
715            Err(_) => continue,
716        };
717
718        let result = match path.as_str() {
719            "/" => write_response(&mut stream, &method, "200 OK", content_type("/"), &html),
720            "/app.js" => write_response(
721                &mut stream,
722                &method,
723                "200 OK",
724                content_type("/app.js"),
725                &app_js,
726            ),
727            "/app.css" => write_response(
728                &mut stream,
729                &method,
730                "200 OK",
731                content_type("/app.css"),
732                &app_css,
733            ),
734            "/session.json" => write_response(
735                &mut stream,
736                &method,
737                "200 OK",
738                content_type("/session.json"),
739                &json,
740            ),
741            _ => write_response(
742                &mut stream,
743                &method,
744                "404 Not Found",
745                content_type(""),
746                "not found",
747            ),
748        };
749
750        if let Err(error) = result {
751            if !matches!(
752                error.kind(),
753                std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
754            ) {
755                return Err(error);
756            }
757        }
758    }
759
760    Ok(())
761}
762
763mod ui {
764    pub fn index_html() -> String {
765        include_str!("../ui/index.html").to_owned()
766    }
767
768    pub fn app_js() -> String {
769        include_str!("../ui/app.js").to_owned()
770    }
771
772    pub fn app_css() -> String {
773        include_str!("../ui/app.css").to_owned()
774    }
775}