Skip to main content

lattice_plugin_trace/
format.rs

1//! PO.4.1 — render a [`PluginTraceRecord`] into one buffer line. Pure +
2//! presentation-only: the tracer hands over structured records (design §5) and
3//! the view owns how they look, so filters/severity can key on the fields while
4//! the line stays human-scannable.
5//!
6//! Shape: `{level} [plugin:{id}] {seam} {call} → {outcome}`, e.g.
7//!   `debug [plugin:3] grammar apply-motion → ok 34µs`
8//!   `debug [plugin:3] grammar apply-operator → ok 12µs, 1.2k fuel`
9//!   `warn  [plugin:3] grammar apply-motion → guest-err`
10//!   `error [plugin:3] grammar apply-motion → trap(fuel)`
11//!   `error [plugin:3] logging log → denied(fs)`
12
13use lattice_plugin_host::{Direction, PluginSeam, PluginTraceRecord, TraceLevel, TraceOutcome};
14
15/// The shared synthetic buffer name + the major mode that owns the trace views.
16pub const SHARED_BUFFER_NAME: &str = "*plugin-trace*";
17pub const TRACE_MODE_ID: &str = "plugin-trace-mode";
18
19/// The per-plugin buffer name for `plugin` (`*plugin-trace:<name>*`) — the
20/// manager `t` drill-in target (PO.4.2). Single-sources the naming scheme with
21/// [`parse_per_plugin_name`] so the manager (producer) and the mode (consumer)
22/// can't drift.
23pub fn per_plugin_buffer_name(plugin: &str) -> String {
24    format!("*plugin-trace:{plugin}*")
25}
26
27/// The plugin name inside a `*plugin-trace:<name>*` buffer name, or `None` for
28/// the shared `*plugin-trace*` (or any non-trace name). Inverse of
29/// [`per_plugin_buffer_name`].
30pub fn parse_per_plugin_name(buffer_name: &str) -> Option<&str> {
31    buffer_name
32        .strip_prefix("*plugin-trace:")
33        .and_then(|rest| rest.strip_suffix('*'))
34}
35
36/// The level tag, right-padded to `error`/`trace` width so lines align in the
37/// column. The label itself is single-sourced on `TraceLevel::as_str`.
38fn level_tag(level: TraceLevel) -> String {
39    format!("{:<5}", level.as_str())
40}
41
42/// Compact a fuel count (`1234` → `1.2k`, `56` → `56`). Trace lines want the
43/// magnitude, not the exact figure.
44fn fuel_short(fuel: u64) -> String {
45    if fuel >= 1000 {
46        format!("{:.1}k", fuel as f64 / 1000.0)
47    } else {
48        fuel.to_string()
49    }
50}
51
52/// The `→ …` outcome cell. Presentation owns the one convention PO.3 folded into
53/// the data model: a `Warn`-level `Ok` is a guest-returned `err` (the grammar
54/// seam's graceful no-op), rendered `guest-err` rather than `ok` so the line
55/// reads truthfully.
56fn outcome_cell(level: TraceLevel, outcome: &TraceOutcome) -> String {
57    match outcome {
58        TraceOutcome::Trap { kind, .. } => format!("trap({kind})"),
59        TraceOutcome::Denied { capability } => format!("denied({capability})"),
60        TraceOutcome::Ok { .. } if level == TraceLevel::Warn => "guest-err".to_string(),
61        TraceOutcome::Ok { micros, fuel_delta } => {
62            let mut s = format!("ok {micros}µs");
63            if *fuel_delta > 0 {
64                s.push_str(&format!(", {} fuel", fuel_short(*fuel_delta)));
65            }
66            s
67        }
68    }
69}
70
71/// The direction glyph: guest→host (a host import the guest called) vs host→guest
72/// (a guest export the host drove). Kept subtle — most records are exports.
73fn direction_arrow(direction: Direction) -> &'static str {
74    match direction {
75        Direction::HostImport => "«",  // guest → host
76        Direction::GuestExport => "»", // host → guest
77    }
78}
79
80/// Format one record into a single line (no trailing newline — the drain joins
81/// with `\n`).
82///
83/// A **logging** record (Layer 2, the guest's own narrative) has no meaningful
84/// timing/outcome — the message is the point — so it renders
85/// `{level} [plugin:{id}] logging {context}: {message}`, with `context` in `call`
86/// and `message` in `detail`. Every other record is a boundary call:
87/// `{level} [plugin:{id}] {seam} {»/«}{call} → {outcome}`.
88pub fn format_trace_line(record: &PluginTraceRecord) -> String {
89    if record.seam == PluginSeam::Logging {
90        let message = record.detail.as_deref().unwrap_or("");
91        let context = record.call.as_ref();
92        return if context.is_empty() {
93            format!(
94                "{lvl} [plugin:{id}] logging: {message}",
95                lvl = level_tag(record.level),
96                id = record.plugin,
97            )
98        } else {
99            format!(
100                "{lvl} [plugin:{id}] logging {context}: {message}",
101                lvl = level_tag(record.level),
102                id = record.plugin,
103            )
104        };
105    }
106    format!(
107        "{lvl} [plugin:{id}] {seam} {dir}{call} → {outcome}",
108        lvl = level_tag(record.level),
109        id = record.plugin,
110        seam = record.seam.as_str(),
111        dir = direction_arrow(record.direction),
112        call = record.call,
113        outcome = outcome_cell(record.level, &record.outcome),
114    )
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use lattice_plugin_host::PluginSeam;
121
122    fn rec(level: TraceLevel, outcome: TraceOutcome) -> PluginTraceRecord {
123        PluginTraceRecord {
124            plugin: 3,
125            seam: PluginSeam::Grammar,
126            direction: Direction::GuestExport,
127            call: "apply-motion".into(),
128            level,
129            outcome,
130            detail: None,
131        }
132    }
133
134    #[test]
135    fn a_success_shows_level_plugin_seam_call_and_timing() {
136        let line = format_trace_line(&rec(
137            TraceLevel::Debug,
138            TraceOutcome::Ok {
139                micros: 34,
140                fuel_delta: 0,
141            },
142        ));
143        assert_eq!(line, "debug [plugin:3] grammar »apply-motion → ok 34µs");
144    }
145
146    #[test]
147    fn fuel_is_appended_and_shortened_when_nonzero() {
148        let line = format_trace_line(&rec(
149            TraceLevel::Debug,
150            TraceOutcome::Ok {
151                micros: 12,
152                fuel_delta: 1234,
153            },
154        ));
155        assert!(line.ends_with("→ ok 12µs, 1.2k fuel"), "got {line}");
156    }
157
158    #[test]
159    fn a_warn_ok_reads_as_guest_err() {
160        let line = format_trace_line(&rec(
161            TraceLevel::Warn,
162            TraceOutcome::Ok {
163                micros: 0,
164                fuel_delta: 0,
165            },
166        ));
167        assert!(line.starts_with("warn "), "got {line}");
168        assert!(line.ends_with("→ guest-err"), "got {line}");
169    }
170
171    #[test]
172    fn a_trap_shows_its_kind() {
173        let line = format_trace_line(&rec(
174            TraceLevel::Error,
175            TraceOutcome::Trap {
176                kind: "fuel".to_string(),
177                func: "apply-motion".to_string(),
178            },
179        ));
180        assert!(line.starts_with("error "), "got {line}");
181        assert!(line.ends_with("→ trap(fuel)"), "got {line}");
182    }
183
184    #[test]
185    fn a_denied_shows_the_capability() {
186        let mut r = rec(
187            TraceLevel::Error,
188            TraceOutcome::Denied {
189                capability: "fs".to_string(),
190            },
191        );
192        r.direction = Direction::HostImport;
193        let line = format_trace_line(&r);
194        assert!(
195            line.contains("«apply-motion"),
196            "guest→host glyph, got {line}"
197        );
198        assert!(line.ends_with("→ denied(fs)"), "got {line}");
199    }
200
201    fn log_rec(context: &str, message: &str) -> PluginTraceRecord {
202        PluginTraceRecord {
203            plugin: 3,
204            seam: PluginSeam::Logging,
205            direction: Direction::HostImport,
206            call: context.to_string().into(),
207            level: TraceLevel::Info,
208            outcome: TraceOutcome::Ok {
209                micros: 0,
210                fuel_delta: 0,
211            },
212            detail: Some(message.to_string()),
213        }
214    }
215
216    #[test]
217    fn a_logging_record_renders_the_message_not_the_outcome() {
218        // The guest's own narrative — no `→ ok 0µs` noise; the message is shown.
219        let line = format_trace_line(&log_rec("parser", "reindexed 40 files"));
220        assert_eq!(line, "info  [plugin:3] logging parser: reindexed 40 files");
221    }
222
223    #[test]
224    fn a_logging_record_without_context_omits_the_category() {
225        let line = format_trace_line(&log_rec("", "hello"));
226        assert_eq!(line, "info  [plugin:3] logging: hello");
227    }
228
229    #[test]
230    fn per_plugin_name_round_trips() {
231        let name = per_plugin_buffer_name("fuzzy-finder");
232        assert_eq!(name, "*plugin-trace:fuzzy-finder*");
233        assert_eq!(parse_per_plugin_name(&name), Some("fuzzy-finder"));
234        // The shared name (and anything else) has no per-plugin id.
235        assert_eq!(parse_per_plugin_name(SHARED_BUFFER_NAME), None);
236        assert_eq!(parse_per_plugin_name("*scratch*"), None);
237    }
238}