1use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use crate::ids::InvalidId;
10
11pub type FaultOptions = serde_json::Map<String, serde_json::Value>;
13
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
27#[serde(try_from = "String", into = "String")]
28pub struct FaultId(#[cfg_attr(feature = "ts", ts(as = "String"))] Cow<'static, str>);
29
30macro_rules! fault_ids {
31 ($($(#[$doc:meta])* $name:ident = $id:literal;)*) => {
32 impl FaultId {
33 $($(#[$doc])* pub const $name: FaultId = FaultId(Cow::Borrowed($id));)*
34 }
35 };
36}
37
38fault_ids! {
39 PAPER_OUT = "paper-out";
41 PAPER_NEAR_END = "paper-near-end";
43 COVER_OPEN = "cover-open";
45 OFFLINE = "offline";
47 DISCONNECT_MID_WRITE = "disconnect-mid-write";
49 SLOW_WRITE = "slow-write";
51 BAUD_MISMATCH = "baud-mismatch";
53 GARBLED = "garbled";
55 TIMEOUT = "timeout";
57 CORS_BLOCKED = "cors-blocked";
59 STALL = "stall";
61 INTERFACE_BUSY = "interface-busy";
63 PICKER_CANCEL = "picker-cancel";
65 PERMISSION_DENIED = "permission-denied";
67 READ_ERROR = "read-error";
69 JAMMED = "jammed";
71 NOT_CONNECTED = "not-connected";
73 DOUBLE_SCAN = "double-scan";
75 PARTIAL_SCAN = "partial-scan";
77}
78
79impl FaultId {
80 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84
85 pub fn lookup(id: &str) -> Option<&'static FaultSpec> {
93 CATALOGUE.iter().find(|spec| spec.id.as_str() == id)
94 }
95}
96
97fn is_kebab_case(s: &str) -> bool {
98 !s.is_empty()
99 && s.len() <= 64
100 && !s.starts_with('-')
101 && !s.ends_with('-')
102 && !s.contains("--")
103 && s.bytes()
104 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
105}
106
107impl FromStr for FaultId {
108 type Err = InvalidId;
109
110 fn from_str(s: &str) -> Result<Self, Self::Err> {
111 if let Some(spec) = FaultId::lookup(s) {
112 return Ok(spec.id.clone());
113 }
114 if is_kebab_case(s) {
115 Ok(FaultId(Cow::Owned(s.to_owned())))
116 } else {
117 Err(InvalidId {
118 what: "fault id",
119 value: s.to_owned(),
120 })
121 }
122 }
123}
124
125impl TryFrom<String> for FaultId {
126 type Error = InvalidId;
127
128 fn try_from(s: String) -> Result<Self, Self::Error> {
129 s.parse()
130 }
131}
132
133impl From<FaultId> for String {
134 fn from(id: FaultId) -> String {
135 id.0.into_owned()
136 }
137}
138
139impl fmt::Display for FaultId {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(&self.0)
142 }
143}
144
145impl schemars::JsonSchema for FaultId {
146 fn schema_name() -> Cow<'static, str> {
147 "FaultId".into()
148 }
149
150 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
151 schemars::json_schema!({
152 "type": "string",
153 "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
154 "maxLength": 64
155 })
156 }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
161#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
162#[serde(untagged)]
163pub enum OptionDefault {
164 Integer(u64),
166 Number(f64),
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
172#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
173pub struct FaultOptionSpec {
174 pub name: &'static str,
176 pub default: OptionDefault,
178 pub description: &'static str,
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[serde(rename_all = "camelCase")]
186pub struct FaultSpec {
187 pub id: FaultId,
189 pub applies_to: &'static str,
191 pub effect: &'static str,
193 pub options: &'static [FaultOptionSpec],
195}
196
197const fn spec(
198 id: FaultId,
199 applies_to: &'static str,
200 effect: &'static str,
201 options: &'static [FaultOptionSpec],
202) -> FaultSpec {
203 FaultSpec {
204 id,
205 applies_to,
206 effect,
207 options,
208 }
209}
210
211const fn opt(
212 name: &'static str,
213 default: OptionDefault,
214 description: &'static str,
215) -> FaultOptionSpec {
216 FaultOptionSpec {
217 name,
218 default,
219 description,
220 }
221}
222
223static CATALOGUE: [FaultSpec; 19] = [
224 spec(
225 FaultId::PAPER_OUT,
226 "printers",
227 "Prints rejected; status reports paper end",
228 &[],
229 ),
230 spec(
231 FaultId::PAPER_NEAR_END,
232 "printers",
233 "Prints succeed; status reports near end",
234 &[],
235 ),
236 spec(
237 FaultId::COVER_OPEN,
238 "printers",
239 "Prints rejected; status reports cover open",
240 &[],
241 ),
242 spec(
243 FaultId::OFFLINE,
244 "printers",
245 "Serial/USB: device disconnects. HTTP: connection refused",
246 &[],
247 ),
248 spec(
249 FaultId::DISCONNECT_MID_WRITE,
250 "serial, USB printers",
251 "Disconnects after N bytes of the next job",
252 &[opt(
253 "afterBytes",
254 OptionDefault::Integer(64),
255 "Bytes of the next job before the disconnect",
256 )],
257 ),
258 spec(
259 FaultId::SLOW_WRITE,
260 "serial, USB printers",
261 "Delays each chunk ack",
262 &[opt(
263 "msPerChunk",
264 OptionDefault::Integer(200),
265 "Delay before each chunk is acknowledged",
266 )],
267 ),
268 spec(
269 FaultId::BAUD_MISMATCH,
270 "serial printers",
271 "Received bytes are scrambled",
272 &[],
273 ),
274 spec(
275 FaultId::GARBLED,
276 "all printers",
277 "Random byte corruption",
278 &[opt(
279 "rate",
280 OptionDefault::Number(0.05),
281 "Probability (0 to 1) that a byte is corrupted",
282 )],
283 ),
284 spec(
285 FaultId::TIMEOUT,
286 "HTTP printers",
287 "Response delayed past the request's timeout",
288 &[],
289 ),
290 spec(
291 FaultId::CORS_BLOCKED,
292 "HTTP printers",
293 "CORS headers omitted",
294 &[],
295 ),
296 spec(
297 FaultId::STALL,
298 "USB printers",
299 "transferOut returns status: \"stall\"",
300 &[],
301 ),
302 spec(
303 FaultId::INTERFACE_BUSY,
304 "USB printers",
305 "claimInterface rejects",
306 &[],
307 ),
308 spec(
309 FaultId::PICKER_CANCEL,
310 "serial, USB, NFC",
311 "Next request* call behaves as user cancel",
312 &[],
313 ),
314 spec(
315 FaultId::PERMISSION_DENIED,
316 "NFC",
317 "scan() rejects NotAllowedError",
318 &[],
319 ),
320 spec(
321 FaultId::READ_ERROR,
322 "NFC",
323 "Next tap fires readingerror",
324 &[],
325 ),
326 spec(
327 FaultId::JAMMED,
328 "drawer",
329 "Kick received, drawer stays closed",
330 &[],
331 ),
332 spec(
333 FaultId::NOT_CONNECTED,
334 "drawer",
335 "Kick ignored; status shows no drawer",
336 &[],
337 ),
338 spec(
339 FaultId::DOUBLE_SCAN,
340 "scanner",
341 "Each scan is sent twice",
342 &[opt(
343 "gapMs",
344 OptionDefault::Integer(30),
345 "Delay between the two scans",
346 )],
347 ),
348 spec(FaultId::PARTIAL_SCAN, "scanner", "Terminator dropped", &[]),
349];
350
351pub fn catalogue() -> &'static [FaultSpec] {
358 &CATALOGUE
359}