Skip to main content

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}