ascii_dag/diagnostics.rs
1//! Typed, run-scoped diagnostics.
2//!
3//! Fatal failures stay in `Result<T, E>` — an `Err` is authoritative
4//! and never copied here. Everything NON-fatal the library wants to
5//! tell you (a placeholder was auto-created, a label had nowhere to
6//! go) travels as typed data through one narrow channel:
7//!
8//! - a [`Diagnostic`] is an owned, lifetime-free record with a stable
9//! machine code, a severity, and a semantic [`DiagnosticKind`] whose
10//! subjects use USER-FACING identity (node ids, input edge indices —
11//! never scene positions);
12//! - a [`DiagnosticSink`] decides storage: collect ([`VecDiagnostics`],
13//! [`SliceDiagnostics`]), count ([`CountingDiagnostics`]), forward
14//! ([`FnDiagnostics`]), or explicitly discard
15//! ([`IgnoreDiagnostics`]) — the library never selects stderr;
16//! - a [`DiagnosticRun`] owns one sink for one logical operation run,
17//! lends [`DiagnosticContext`]s to context-taking entry points, and
18//! [`finish`](DiagnosticRun::finish)es into a [`Report`] pairing the
19//! outcome with everything collected.
20//!
21//! Emission is best-effort and can never fail an operation: a bounded
22//! sink that fills up counts what it dropped, and storage exhaustion
23//! never turns a warning into an unrelated failure. Diagnostics are
24//! deterministic public output: both layout backends emit the same
25//! codes, subjects, and order for the same graph.
26//!
27//! The compatibility promise: existing codes are never repurposed and
28//! never change severity or subject meaning; new kinds and codes are
29//! additive; match on [`DiagnosticKind`] non-exhaustively.
30
31/// How serious a diagnostic is. Only the classes the channel carries
32/// today are defined; further classes are added when a diagnostic
33/// actually uses them.
34#[non_exhaustive]
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Severity {
37 /// The operation succeeded; something is worth knowing.
38 Warning,
39 /// A validation failure reported alongside a primary error.
40 Error,
41}
42
43/// What happened — flat semantic kinds, organized by meaning rather
44/// than by the pipeline stage that currently detects them (a condition
45/// may move between stages without changing its public identity).
46#[non_exhaustive]
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DiagnosticKind {
49 /// An edge referenced an undeclared node and the graph
50 /// auto-created a placeholder under the IMPLICIT missing-node
51 /// policy (an explicitly chosen policy is declared intent and does
52 /// not warn — the [`EdgeInsertion`](crate::EdgeInsertion) receipt
53 /// is the record either way).
54 PlaceholderCreated {
55 /// The auto-created node's id.
56 node: usize,
57 },
58 /// An edge label found no inline position and the overflow policy
59 /// says omit — the label appears nowhere in the output.
60 LabelOmitted {
61 /// The edge's input index (insertion order — the same identity
62 /// style callbacks and `EdgeView::input_index` use; self-loops
63 /// count in this numbering).
64 edge: usize,
65 },
66 /// `crossing_reduction_passes` was unreasonably large (possibly a
67 /// negative value cast to `usize`) and was clamped.
68 CrossingPassesClamped {
69 /// The requested pass count.
70 requested: usize,
71 /// What it was clamped to.
72 clamped_to: usize,
73 },
74 /// `crossing_reduction_passes` is high enough for diminishing
75 /// returns; the value was kept.
76 CrossingPassesExcessive {
77 /// The requested (and kept) pass count.
78 requested: usize,
79 },
80 /// A self-loop declared a side; sides on self-loops are not honored
81 /// in this release and the loop keeps its marker cell.
82 PortIgnoredOnSelfLoop {
83 /// The edge's input index.
84 edge: usize,
85 },
86 /// A declared side could not be routed — no free lane beside the
87 /// node, or the face cell was already taken — and the end attached
88 /// on its role's own face instead.
89 PortUnroutable {
90 /// The edge's input index.
91 edge: usize,
92 /// Which declared end.
93 end: crate::EdgeEnd,
94 /// The side the declaration named, physically.
95 requested: crate::PhysicalSide,
96 /// The side the end attached on.
97 resolved: crate::PhysicalSide,
98 },
99}
100
101/// What a diagnostic is about, normalized across kinds: the stable
102/// half of the `(code, subject)` identity. Always USER-FACING
103/// identity — node ids and input edge indices, never scene positions
104/// or backend-internal slots.
105#[non_exhaustive]
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum DiagnosticSubject {
108 /// A node, by its user-facing id.
109 Node(usize),
110 /// An edge, by its input index (insertion order — self-loops
111 /// count in this numbering).
112 Edge(usize),
113 /// Graph-wide configuration rather than a single element.
114 Configuration,
115}
116
117/// One diagnostic event: an owned, lifetime-free record. Construction
118/// is crate-internal; consumers read [`kind`](Self::kind),
119/// [`code`](Self::code), [`severity`](Self::severity),
120/// [`subject`](Self::subject), and [`hint`](Self::hint).
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct Diagnostic {
123 kind: DiagnosticKind,
124}
125
126impl Diagnostic {
127 pub(crate) fn new(kind: DiagnosticKind) -> Self {
128 Self { kind }
129 }
130
131 /// What happened.
132 pub fn kind(&self) -> &DiagnosticKind {
133 &self.kind
134 }
135
136 /// The permanent machine identity (a WDP code such as
137 /// `W.Render.Label.031`): never repurposed, never a different
138 /// severity, never a different subject interpretation.
139 pub fn code(&self) -> &'static str {
140 match self.kind {
141 DiagnosticKind::PlaceholderCreated { .. } => crate::errors::WARN_NODE_AUTO_CREATED,
142 DiagnosticKind::LabelOmitted { .. } => crate::errors::WARN_LABEL_INVISIBLE,
143 DiagnosticKind::CrossingPassesClamped { .. } => crate::errors::WARN_CONFIG_CLAMPED,
144 DiagnosticKind::CrossingPassesExcessive { .. } => crate::errors::WARN_CONFIG_EXCESSIVE,
145 DiagnosticKind::PortIgnoredOnSelfLoop { .. } => crate::errors::WARN_PORT_ON_SELF_LOOP,
146 DiagnosticKind::PortUnroutable { .. } => crate::errors::WARN_PORT_UNROUTABLE,
147 }
148 }
149
150 /// Severity, derived from the kind — never a free-floating field.
151 pub fn severity(&self) -> Severity {
152 match self.kind {
153 DiagnosticKind::PlaceholderCreated { .. }
154 | DiagnosticKind::LabelOmitted { .. }
155 | DiagnosticKind::CrossingPassesClamped { .. }
156 | DiagnosticKind::CrossingPassesExcessive { .. }
157 | DiagnosticKind::PortIgnoredOnSelfLoop { .. }
158 | DiagnosticKind::PortUnroutable { .. } => Severity::Warning,
159 }
160 }
161
162 /// The user-facing subject this diagnostic is about — the stable
163 /// half of the `(code, subject)` identity, normalized so generic
164 /// consumers (dedup keys, grouping, suppression lists) need not
165 /// understand every [`DiagnosticKind`] variant, including ones
166 /// added after they were written.
167 pub fn subject(&self) -> DiagnosticSubject {
168 match self.kind {
169 DiagnosticKind::PlaceholderCreated { node } => DiagnosticSubject::Node(node),
170 DiagnosticKind::LabelOmitted { edge } => DiagnosticSubject::Edge(edge),
171 DiagnosticKind::CrossingPassesClamped { .. }
172 | DiagnosticKind::CrossingPassesExcessive { .. } => DiagnosticSubject::Configuration,
173 DiagnosticKind::PortIgnoredOnSelfLoop { edge }
174 | DiagnosticKind::PortUnroutable { edge, .. } => DiagnosticSubject::Edge(edge),
175 }
176 }
177
178 /// Actionable, static advice for this kind of event.
179 pub fn hint(&self) -> &'static str {
180 match self.kind {
181 DiagnosticKind::PlaceholderCreated { .. } => {
182 "Call add_node(id, \"label\") before add_edge(), or declare the \
183 missing-node policy explicitly with set_missing_node_policy()"
184 }
185 DiagnosticKind::LabelOmitted { .. } => {
186 "Set LabelOverflow::Legend to list unplaced labels below the graph"
187 }
188 DiagnosticKind::CrossingPassesClamped { .. } => {
189 "Pass a small positive count (a negative value cast to usize \
190 arrives enormous and is clamped)"
191 }
192 DiagnosticKind::CrossingPassesExcessive { .. } => {
193 "Values above 20 have diminishing returns and may be slow"
194 }
195 DiagnosticKind::PortIgnoredOnSelfLoop { .. } => {
196 "Sides on self-loops are not honored yet; the loop keeps its marker"
197 }
198 DiagnosticKind::PortUnroutable { .. } => {
199 "Leave room beside the node (node_spacing ≥ 1, no self-loop marker in \
200 the way) or declare a side the node has a free cell for"
201 }
202 }
203 }
204}
205
206impl core::fmt::Display for Diagnostic {
207 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208 write!(f, "[{}] ", self.code())?;
209 match self.kind {
210 DiagnosticKind::PlaceholderCreated { node } => {
211 write!(f, "node {node} auto-created as a placeholder")
212 }
213 DiagnosticKind::LabelOmitted { edge } => {
214 write!(f, "the label of edge {edge} will not be rendered")
215 }
216 DiagnosticKind::CrossingPassesClamped {
217 requested,
218 clamped_to,
219 } => {
220 write!(
221 f,
222 "crossing_reduction_passes={requested} clamped to {clamped_to}"
223 )
224 }
225 DiagnosticKind::CrossingPassesExcessive { requested } => {
226 write!(f, "crossing_reduction_passes={requested} is high")
227 }
228 DiagnosticKind::PortIgnoredOnSelfLoop { edge } => {
229 write!(
230 f,
231 "edge {edge}: the declared side of a self-loop is not honored"
232 )
233 }
234 DiagnosticKind::PortUnroutable {
235 edge,
236 end,
237 requested,
238 resolved,
239 } => {
240 write!(
241 f,
242 "edge {edge}: the {} end could not attach {}, attached {}",
243 match end {
244 crate::EdgeEnd::Source => "source",
245 crate::EdgeEnd::Target => "target",
246 },
247 requested.name(),
248 resolved.name()
249 )
250 }
251 }
252 }
253}
254
255/// Where diagnostics go. Object-safe; `emit` is best-effort and can
256/// never fail the operation that produced the diagnostic.
257pub trait DiagnosticSink {
258 /// Record one diagnostic (by value — records are owned and
259 /// lifetime-free).
260 fn emit(&mut self, diagnostic: Diagnostic);
261 /// How many diagnostics this sink could not retain (bounded sinks;
262 /// `0` for unbounded and non-retaining sinks).
263 fn dropped(&self) -> usize {
264 0
265 }
266 /// The retained diagnostics, for sinks that store them (empty for
267 /// counting/ignoring sinks).
268 fn entries(&self) -> &[Diagnostic] {
269 &[]
270 }
271}
272
273/// Explicit discard: zero storage. The way quiet conveniences opt out
274/// — visibly, in their implementation — rather than any API silently
275/// falling back to a log.
276#[derive(Debug, Default)]
277pub struct IgnoreDiagnostics;
278
279impl DiagnosticSink for IgnoreDiagnostics {
280 fn emit(&mut self, _diagnostic: Diagnostic) {}
281}
282
283/// Counts by severity without retaining entries.
284#[derive(Debug, Default)]
285pub struct CountingDiagnostics {
286 warnings: usize,
287 errors: usize,
288}
289
290impl CountingDiagnostics {
291 /// Warnings seen.
292 pub fn warnings(&self) -> usize {
293 self.warnings
294 }
295 /// Validation errors seen.
296 pub fn errors(&self) -> usize {
297 self.errors
298 }
299}
300
301impl DiagnosticSink for CountingDiagnostics {
302 fn emit(&mut self, diagnostic: Diagnostic) {
303 match diagnostic.severity() {
304 Severity::Warning => self.warnings += 1,
305 Severity::Error => self.errors += 1,
306 #[allow(unreachable_patterns)] // future severities count as-yet-unclassified
307 _ => {}
308 }
309 }
310}
311
312/// Writes into caller-provided storage; when full, keeps the
313/// deterministic prefix and counts the rest as dropped.
314///
315/// Storage is `MaybeUninit` because a [`Diagnostic`] cannot be
316/// constructed by callers (there is no vacuous "nothing happened"
317/// record to fill an array with) — and `Diagnostic` is `Copy`, so a
318/// buffer is one array literal away:
319///
320/// ```
321/// use core::mem::MaybeUninit;
322/// use ascii_dag::{Diagnostic, SliceDiagnostics};
323///
324/// let mut storage = [MaybeUninit::<Diagnostic>::uninit(); 8];
325/// let sink = SliceDiagnostics::new(&mut storage);
326/// assert!(sink.entries().is_empty());
327/// ```
328#[derive(Debug)]
329pub struct SliceDiagnostics<'a> {
330 storage: &'a mut [core::mem::MaybeUninit<Diagnostic>],
331 len: usize,
332 dropped: usize,
333}
334
335impl<'a> SliceDiagnostics<'a> {
336 /// Collect into `storage` (capacity = its length).
337 pub fn new(storage: &'a mut [core::mem::MaybeUninit<Diagnostic>]) -> Self {
338 Self {
339 storage,
340 len: 0,
341 dropped: 0,
342 }
343 }
344}
345
346impl SliceDiagnostics<'_> {
347 /// The retained diagnostics (the deterministic prefix when full).
348 pub fn entries(&self) -> &[Diagnostic] {
349 // SAFETY: `emit` wrote `storage[i]` before ever growing `len`
350 // past `i`, `len` never shrinks, and `MaybeUninit<Diagnostic>`
351 // has the same layout as `Diagnostic` — so the first `len`
352 // slots are initialized.
353 unsafe { core::slice::from_raw_parts(self.storage.as_ptr().cast::<Diagnostic>(), self.len) }
354 }
355}
356
357impl DiagnosticSink for SliceDiagnostics<'_> {
358 fn emit(&mut self, diagnostic: Diagnostic) {
359 if self.len < self.storage.len() {
360 self.storage[self.len].write(diagnostic);
361 self.len += 1;
362 } else {
363 self.dropped += 1;
364 }
365 }
366 fn dropped(&self) -> usize {
367 self.dropped
368 }
369 fn entries(&self) -> &[Diagnostic] {
370 SliceDiagnostics::entries(self)
371 }
372}
373
374/// `alloc` convenience: collects everything, reusable capacity.
375#[cfg(feature = "alloc")]
376#[derive(Debug, Default)]
377pub struct VecDiagnostics {
378 entries: alloc::vec::Vec<Diagnostic>,
379}
380
381#[cfg(feature = "alloc")]
382impl VecDiagnostics {
383 /// The retained diagnostics, in discovery order.
384 pub fn entries(&self) -> &[Diagnostic] {
385 &self.entries
386 }
387}
388
389#[cfg(feature = "alloc")]
390impl DiagnosticSink for VecDiagnostics {
391 fn emit(&mut self, diagnostic: Diagnostic) {
392 self.entries.push(diagnostic);
393 }
394 fn entries(&self) -> &[Diagnostic] {
395 &self.entries
396 }
397}
398
399/// Callback adapter: forward each diagnostic to a closure.
400pub struct FnDiagnostics<F: FnMut(Diagnostic)>(pub F);
401
402impl<F: FnMut(Diagnostic)> DiagnosticSink for FnDiagnostics<F> {
403 fn emit(&mut self, diagnostic: Diagnostic) {
404 (self.0)(diagnostic);
405 }
406}
407
408/// Run-wide tallies, kept by the run regardless of sink choice.
409#[derive(Debug, Default, Clone, Copy)]
410pub struct DiagnosticCounts {
411 warnings: usize,
412 errors: usize,
413}
414
415impl DiagnosticCounts {
416 /// Warnings emitted this run.
417 pub fn warnings(&self) -> usize {
418 self.warnings
419 }
420 /// Validation errors emitted this run.
421 pub fn errors(&self) -> usize {
422 self.errors
423 }
424}
425
426/// One logical operation run: owns the sink and the run-wide counts,
427/// lends [`DiagnosticContext`]s to any number of phase calls, and
428/// [`finish`](Self::finish)es into a [`Report`]. Starting a new run
429/// starts a new diagnostic set.
430#[derive(Debug, Default)]
431pub struct DiagnosticRun<S> {
432 sink: S,
433 counts: DiagnosticCounts,
434}
435
436impl<S: DiagnosticSink> DiagnosticRun<S> {
437 /// A run collecting into `sink`.
438 pub fn new(sink: S) -> Self {
439 Self {
440 sink,
441 counts: DiagnosticCounts::default(),
442 }
443 }
444
445 /// Borrow a context for one or more phase calls. The borrow ends
446 /// when the context drops; the run keeps accumulating across
447 /// contexts.
448 pub fn context(&mut self) -> DiagnosticContext<'_> {
449 DiagnosticContext {
450 sink: &mut self.sink,
451 counts: &mut self.counts,
452 }
453 }
454
455 /// Run-wide tallies so far.
456 pub fn counts(&self) -> DiagnosticCounts {
457 self.counts
458 }
459
460 /// Package the run: the operation's outcome plus everything the
461 /// sink retained. The `Err` inside `outcome` is authoritative and
462 /// is NOT duplicated into the diagnostics.
463 pub fn finish<T, E>(self, outcome: Result<T, E>) -> Report<T, E, S> {
464 let dropped = self.sink.dropped();
465 Report {
466 outcome,
467 diagnostics: self.sink,
468 dropped_diagnostics: dropped,
469 }
470 }
471}
472
473/// A temporary borrow of a [`DiagnosticRun`], passed through
474/// context-taking entry points. Internal code emits through this
475/// without becoming generic over the sink.
476pub struct DiagnosticContext<'a> {
477 sink: &'a mut dyn DiagnosticSink,
478 counts: &'a mut DiagnosticCounts,
479}
480
481impl DiagnosticContext<'_> {
482 pub(crate) fn emit(&mut self, kind: DiagnosticKind) {
483 self.emit_diagnostic(Diagnostic::new(kind));
484 }
485
486 pub(crate) fn emit_diagnostic(&mut self, diagnostic: Diagnostic) {
487 match diagnostic.severity() {
488 Severity::Warning => self.counts.warnings += 1,
489 Severity::Error => self.counts.errors += 1,
490 #[allow(unreachable_patterns)]
491 _ => {}
492 }
493 self.sink.emit(diagnostic);
494 }
495}
496
497/// One operation, packaged: the outcome and the diagnostics collected
498/// alongside it. The primary `Err` stays in the outcome (authoritative,
499/// never duplicated into storage).
500#[derive(Debug)]
501pub struct Report<T, E, D> {
502 outcome: Result<T, E>,
503 diagnostics: D,
504 dropped_diagnostics: usize,
505}
506
507/// Owned heap-side report.
508#[cfg(feature = "alloc")]
509pub type OwnedReport<T, E> = Report<T, E, VecDiagnostics>;
510
511/// Report borrowing caller storage (no-alloc).
512pub type BorrowedReport<'a, T, E> = Report<T, E, SliceDiagnostics<'a>>;
513
514/// A failure type the unified [`Report::diagnostics`] stream can
515/// project: the authoritative primary error, plus a walkable causal
516/// chain when the type carries one. Implemented for the crate's error
517/// types; external `E` types opt in by implementing it (the default
518/// `cause` reports no chain).
519pub trait ProjectedFailure {
520 /// The next cause beneath this failure, if any.
521 fn cause(&self) -> Option<&Self> {
522 None
523 }
524}
525
526impl ProjectedFailure for crate::GraphError {}
527
528#[cfg(feature = "alloc")]
529impl ProjectedFailure for crate::errors::ErrorChain {
530 fn cause(&self) -> Option<&Self> {
531 self.cause()
532 }
533}
534
535impl ProjectedFailure for core::convert::Infallible {}
536
537/// One item in the unified presentation stream of
538/// [`Report::diagnostics`]: retained sidecar records first (discovery
539/// order), then the authoritative primary failure — projected, never
540/// copied into storage — then its causes, marked as causes.
541#[derive(Debug, Clone, Copy)]
542pub enum DiagnosticRef<'a, E> {
543 /// A retained sidecar diagnostic.
544 Retained(&'a Diagnostic),
545 /// The primary failure from the outcome (authoritative — this is
546 /// a projection of the `Err`, which remains in the outcome).
547 Failure(&'a E),
548 /// A cause beneath the primary failure.
549 Cause(&'a E),
550}
551
552/// Walks a [`ProjectedFailure`] cause chain.
553struct Causes<'a, E> {
554 next: Option<&'a E>,
555}
556
557impl<'a, E: ProjectedFailure> Iterator for Causes<'a, E> {
558 type Item = &'a E;
559 fn next(&mut self) -> Option<&'a E> {
560 let current = self.next?;
561 self.next = current.cause();
562 Some(current)
563 }
564}
565
566impl<T, E, D: DiagnosticSink> Report<T, E, D> {
567 /// The operation's outcome.
568 pub fn outcome(&self) -> Result<&T, &E> {
569 self.outcome.as_ref()
570 }
571
572 /// One logical stream for presenters: every retained diagnostic
573 /// (deterministic discovery order), then the primary failure
574 /// projected from the outcome, then its causal chain — so a
575 /// presenter iterating this method alone misses nothing. The
576 /// failure is a projection: it stays authoritative in
577 /// [`outcome`](Self::outcome) and is never copied into storage.
578 pub fn diagnostics(&self) -> impl Iterator<Item = DiagnosticRef<'_, E>>
579 where
580 E: ProjectedFailure,
581 {
582 let failure = self.outcome.as_ref().err();
583 self.retained_diagnostics()
584 .map(DiagnosticRef::Retained)
585 .chain(failure.map(DiagnosticRef::Failure))
586 .chain(
587 Causes {
588 next: failure.and_then(ProjectedFailure::cause),
589 }
590 .map(DiagnosticRef::Cause),
591 )
592 }
593
594 /// Only what the sink retained, in deterministic discovery order —
595 /// the storage view (tests, tooling). The primary failure is never
596 /// among these; [`diagnostics`](Self::diagnostics) is the complete
597 /// presentation stream.
598 pub fn retained_diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
599 self.diagnostics.entries().iter()
600 }
601
602 /// Retained warnings only.
603 pub fn warnings(&self) -> impl Iterator<Item = &Diagnostic> {
604 self.retained_diagnostics()
605 .filter(|d| matches!(d.severity(), Severity::Warning))
606 }
607
608 /// How many diagnostics a bounded sink could not retain. The
609 /// primary error is never among them.
610 pub fn dropped_diagnostics(&self) -> usize {
611 self.dropped_diagnostics
612 }
613
614 /// Split into the outcome and the sink (with whatever it retained).
615 pub fn into_parts(self) -> (Result<T, E>, D) {
616 (self.outcome, self.diagnostics)
617 }
618}