pub mod flowchart;
pub mod mermaid;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SupportLevel {
Source,
Text,
Graphics,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackReason {
Unregistered,
SurfaceCannot,
OverBudget(Budget),
HandlerDeclined,
ResultRefused(Budget),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Budget {
SourceBytes,
Nodes,
Edges,
Depth,
OutputBytes,
Time,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Budgets {
pub source_bytes: usize,
pub nodes: usize,
pub edges: usize,
pub depth: usize,
pub output_bytes: usize,
pub time_nanos: u128,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Shape {
pub nodes: usize,
pub edges: usize,
pub depth: usize,
}
pub trait Stopwatch {
fn read_nanos(&self) -> u128;
}
#[derive(Debug, Clone, Copy)]
pub struct Monotonic(std::time::Instant);
impl Default for Monotonic {
fn default() -> Self {
Self(std::time::Instant::now())
}
}
impl Stopwatch for Monotonic {
fn read_nanos(&self) -> u128 {
self.0.elapsed().as_nanos()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Enhancement {
level: SupportLevel,
payload: String,
accessible_text: String,
}
impl Enhancement {
#[must_use]
pub fn text(payload: impl Into<String>, accessible_text: impl Into<String>) -> Option<Self> {
Self::build(SupportLevel::Text, payload.into(), accessible_text.into())
}
#[must_use]
pub fn graphics(
payload: impl Into<String>,
accessible_text: impl Into<String>,
) -> Option<Self> {
Self::build(
SupportLevel::Graphics,
payload.into(),
accessible_text.into(),
)
}
fn build(level: SupportLevel, payload: String, accessible_text: String) -> Option<Self> {
(!payload.trim().is_empty() && !accessible_text.trim().is_empty()).then_some(Self {
level,
payload,
accessible_text,
})
}
#[must_use]
pub fn level(&self) -> SupportLevel {
self.level
}
#[must_use]
pub fn payload(&self) -> &str {
&self.payload
}
#[must_use]
pub fn accessible_text(&self) -> &str {
&self.accessible_text
}
}
pub trait Extension: Send + Sync {
fn info(&self) -> &'static str;
fn budgets(&self) -> Budgets;
fn measure(&self, source: &str) -> Shape;
fn render(&self, source: &str, level: SupportLevel) -> Option<Enhancement>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
pub highest: SupportLevel,
}
impl Capabilities {
#[must_use]
pub const fn source_only() -> Self {
Self {
highest: SupportLevel::Source,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Presentation<'a> {
info: &'a str,
source: &'a str,
enhancement: Option<Enhancement>,
fallback: Option<FallbackReason>,
}
impl<'a> Presentation<'a> {
#[must_use]
pub fn info(&self) -> &'a str {
self.info
}
#[must_use]
pub fn source(&self) -> &'a str {
self.source
}
#[must_use]
pub fn enhancement(&self) -> Option<&Enhancement> {
self.enhancement.as_ref()
}
#[must_use]
pub fn fallback(&self) -> Option<FallbackReason> {
self.fallback
}
fn fell_back(info: &'a str, source: &'a str, why: FallbackReason) -> Self {
Self {
info,
source,
enhancement: None,
fallback: Some(why),
}
}
fn enhanced(info: &'a str, source: &'a str, enhancement: Enhancement) -> Self {
Self {
info,
source,
enhancement: Some(enhancement),
fallback: None,
}
}
}
#[derive(Default)]
pub struct Registry {
by_info: BTreeMap<&'static str, &'static dyn Extension>,
}
impl Registry {
#[must_use]
pub fn new() -> Self {
Self {
by_info: BTreeMap::new(),
}
}
#[must_use]
pub fn with(mut self, extension: &'static dyn Extension) -> Self {
self.by_info.insert(extension.info(), extension);
self
}
pub fn present<'a>(
&self,
info: &'a str,
source: &'a str,
caps: Capabilities,
watch: &dyn Stopwatch,
) -> Presentation<'a> {
let key = info.split_whitespace().next().unwrap_or("");
let lowered = key.to_ascii_lowercase();
let Some(extension) = self.by_info.get(lowered.as_str()).copied() else {
return Presentation::fell_back(info, source, FallbackReason::Unregistered);
};
if caps.highest <= SupportLevel::Source {
return Presentation::fell_back(info, source, FallbackReason::SurfaceCannot);
}
let budgets = extension.budgets();
if source.len() > budgets.source_bytes {
return Presentation::fell_back(
info,
source,
FallbackReason::OverBudget(Budget::SourceBytes),
);
}
let shape = extension.measure(source);
for (over, which) in [
(shape.nodes > budgets.nodes, Budget::Nodes),
(shape.edges > budgets.edges, Budget::Edges),
(shape.depth > budgets.depth, Budget::Depth),
] {
if over {
return Presentation::fell_back(info, source, FallbackReason::OverBudget(which));
}
}
let started = watch.read_nanos();
let produced = extension.render(source, caps.highest);
let took = watch.read_nanos().saturating_sub(started);
let Some(enhancement) = produced else {
return Presentation::fell_back(info, source, FallbackReason::HandlerDeclined);
};
if enhancement.payload().len() > budgets.output_bytes {
return Presentation::fell_back(
info,
source,
FallbackReason::ResultRefused(Budget::OutputBytes),
);
}
if took > budgets.time_nanos {
return Presentation::fell_back(
info,
source,
FallbackReason::ResultRefused(Budget::Time),
);
}
if enhancement.level() > caps.highest {
return Presentation::fell_back(info, source, FallbackReason::SurfaceCannot);
}
Presentation::enhanced(info, source, enhancement)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct Scripted {
readings: Cell<usize>,
values: Vec<u128>,
}
impl Scripted {
fn new(values: &[u128]) -> Self {
Self {
readings: Cell::new(0),
values: values.to_vec(),
}
}
}
impl Stopwatch for Scripted {
fn read_nanos(&self) -> u128 {
let i = self.readings.get();
self.readings.set(i + 1);
self.values.get(i).copied().unwrap_or(0)
}
}
struct Frozen;
impl Stopwatch for Frozen {
fn read_nanos(&self) -> u128 {
0
}
}
struct Probe {
shape: Shape,
payload_len: usize,
declines: bool,
budgets: Budgets,
level: SupportLevel,
}
const ROOMY: Budgets = Budgets {
source_bytes: 1024,
nodes: 100,
edges: 100,
depth: 10,
output_bytes: 1024,
time_nanos: 1_000,
};
impl Default for Probe {
fn default() -> Self {
Self {
shape: Shape::default(),
payload_len: 8,
declines: false,
budgets: ROOMY,
level: SupportLevel::Graphics,
}
}
}
impl Extension for Probe {
fn info(&self) -> &'static str {
"probe"
}
fn budgets(&self) -> Budgets {
self.budgets
}
fn measure(&self, _source: &str) -> Shape {
self.shape
}
fn render(&self, _source: &str, _level: SupportLevel) -> Option<Enhancement> {
if self.declines {
return None;
}
let payload = "x".repeat(self.payload_len);
match self.level {
SupportLevel::Graphics => Enhancement::graphics(payload, "alt"),
SupportLevel::Text => Enhancement::text(payload, "alt"),
SupportLevel::Source => None,
}
}
}
fn registry_of(probe: Probe) -> Registry {
Registry::new().with(Box::leak(Box::new(probe)))
}
fn graphics() -> Capabilities {
Capabilities {
highest: SupportLevel::Graphics,
}
}
#[test]
fn source_is_never_lost_on_any_path() {
const SRC: &str = "graph TD\n A --> B";
let cases: Vec<(&str, Presentation<'_>)> = vec![
(
"unregistered info string",
Registry::new().present("nosuch", SRC, graphics(), &Frozen),
),
(
"surface cannot enhance",
registry_of(Probe::default()).present(
"probe",
SRC,
Capabilities::source_only(),
&Frozen,
),
),
(
"handler declined",
registry_of(Probe {
declines: true,
..Probe::default()
})
.present("probe", SRC, graphics(), &Frozen),
),
(
"input over budget",
registry_of(Probe {
shape: Shape {
nodes: 1_000,
..Shape::default()
},
..Probe::default()
})
.present("probe", SRC, graphics(), &Frozen),
),
(
"output over budget",
registry_of(Probe {
payload_len: 99_999,
..Probe::default()
})
.present("probe", SRC, graphics(), &Frozen),
),
(
"over the time budget",
registry_of(Probe::default()).present(
"probe",
SRC,
graphics(),
&Scripted::new(&[0, 999_999]),
),
),
];
for (what, presentation) in cases {
assert_eq!(presentation.source(), SRC, "[{what}] source was lost");
assert!(
presentation.enhancement().is_none(),
"[{what}] should not have enhanced"
);
assert!(
presentation.fallback().is_some(),
"[{what}] a fallback must say why"
);
}
}
#[test]
fn an_enhanced_presentation_still_carries_its_source() {
const SRC: &str = "graph TD";
let p = registry_of(Probe::default()).present("probe", SRC, graphics(), &Frozen);
let enhancement = p.enhancement().expect("this registrant does enhance");
assert_eq!(enhancement.level(), SupportLevel::Graphics);
assert_eq!(p.fallback(), None, "an enhanced presentation has no reason");
assert_eq!(
p.source(),
SRC,
"the source rides along even when it is not what gets drawn"
);
}
#[test]
fn a_malformed_enhancement_is_unrepresentable() {
assert!(Enhancement::graphics("", "alt").is_none(), "empty payload");
assert!(
Enhancement::graphics(" ", "alt").is_none(),
"blank payload"
);
assert!(
Enhancement::graphics("<svg/>", "").is_none(),
"graphics with no accessible text is unreadable to a screen reader"
);
assert!(Enhancement::text("", "alt").is_none());
assert!(Enhancement::text("t", " ").is_none());
assert!(Enhancement::graphics("<svg/>", "a flowchart").is_some());
}
#[test]
fn each_budget_is_enforced_at_its_own_boundary() {
let at_limit = Budgets {
source_bytes: 4,
nodes: 2,
edges: 3,
depth: 1,
output_bytes: 5,
time_nanos: 10,
};
let probe = |shape: Shape, payload_len: usize| Probe {
shape,
payload_len,
budgets: at_limit,
..Probe::default()
};
let full = Shape {
nodes: 2,
edges: 3,
depth: 1,
};
let p = registry_of(probe(full, 5)).present("probe", "1234", graphics(), &Frozen);
assert!(
p.enhancement().is_some(),
"exactly at every limit must still render: {:?}",
p.fallback()
);
let over: [(&str, Shape, usize, &str, Budget); 5] = [
("source bytes", full, 5, "12345", Budget::SourceBytes),
(
"nodes",
Shape { nodes: 3, ..full },
5,
"1234",
Budget::Nodes,
),
(
"edges",
Shape { edges: 4, ..full },
5,
"1234",
Budget::Edges,
),
(
"depth",
Shape { depth: 2, ..full },
5,
"1234",
Budget::Depth,
),
("output bytes", full, 6, "1234", Budget::OutputBytes),
];
for (what, shape, payload_len, source, expected) in over {
let p = registry_of(probe(shape, payload_len)).present(
"probe",
source,
graphics(),
&Frozen,
);
assert!(
p.enhancement().is_none(),
"[{what}] one over the limit must not render"
);
let expected_reason = if matches!(expected, Budget::OutputBytes) {
FallbackReason::ResultRefused(expected)
} else {
FallbackReason::OverBudget(expected)
};
assert_eq!(
p.fallback(),
Some(expected_reason),
"[{what}] must name the bound that fired"
);
}
let p = registry_of(probe(full, 5)).present(
"probe",
"1234",
graphics(),
&Scripted::new(&[100, 110]),
);
assert!(
p.enhancement().is_some(),
"10ns elapsed is exactly the limit"
);
let p = registry_of(probe(full, 5)).present(
"probe",
"1234",
graphics(),
&Scripted::new(&[100, 111]),
);
assert_eq!(
p.fallback(),
Some(FallbackReason::ResultRefused(Budget::Time)),
"one nanosecond over must refuse, and say it was time"
);
}
#[test]
fn an_over_budget_source_never_reaches_the_handler() {
struct Exploding;
impl Extension for Exploding {
fn info(&self) -> &'static str {
"probe"
}
fn budgets(&self) -> Budgets {
Budgets {
source_bytes: 2,
..ROOMY
}
}
fn measure(&self, _: &str) -> Shape {
Shape::default()
}
fn render(&self, _: &str, _: SupportLevel) -> Option<Enhancement> {
panic!("the registry must refuse before the handler runs");
}
}
let p =
Registry::new()
.with(&Exploding)
.present("probe", "far too long", graphics(), &Frozen);
assert_eq!(
p.fallback(),
Some(FallbackReason::OverBudget(Budget::SourceBytes))
);
}
#[test]
fn the_info_string_matches_its_first_token_case_insensitively() {
let reg = registry_of(Probe::default());
for info in ["probe", "PROBE", "Probe extra args"] {
assert!(
reg.present(info, "s", graphics(), &Frozen)
.enhancement()
.is_some(),
"{info:?} should match"
);
}
for info in ["probes", "notprobe", ""] {
assert_eq!(
reg.present(info, "s", graphics(), &Frozen).fallback(),
Some(FallbackReason::Unregistered),
"{info:?} must not match"
);
}
}
#[test]
fn an_enhancement_above_the_surfaces_level_is_refused() {
let reg = registry_of(Probe {
level: SupportLevel::Graphics,
..Probe::default()
});
let p = reg.present(
"probe",
"s",
Capabilities {
highest: SupportLevel::Text,
},
&Frozen,
);
assert!(p.enhancement().is_none(), "graphics to a text-only surface");
assert_eq!(p.fallback(), Some(FallbackReason::SurfaceCannot));
}
}