cuttlefish_abi/lib.rs
1//! The contract between the cuttlefish host and its guest proc-blocks.
2//!
3//! Both sides depend on this crate precisely so that they cannot drift: a block
4//! is compiled separately from the host, often at a different time by a
5//! different person, and the only thing keeping them able to talk is that they
6//! agreed on these types.
7//!
8//! # Why a command loop, not function calls
9//!
10//! A block does not call the host. It *returns* a [`Command`] describing what it
11//! wants done, and the host — after doing it — hands back an [`Event`] and asks
12//! for the next command. Control is inverted relative to the obvious design, and
13//! not for taste:
14//!
15//! - A core-wasm guest is single-threaded and offers no execution context the
16//! host could call back into while the guest is blocked. A "call the host and
17//! wait" design has nowhere to deliver the answer.
18//! - Inference must run on a different thread from the wasm store, which is
19//! `!Sync` and cannot be touched from there.
20//! - Because the host decides whether to take the next step, cancellation needs
21//! no cooperation from the guest at all: the host simply stops stepping. A
22//! guest cannot ignore, delay, or trap its way out of being cancelled.
23//!
24//! Everything crosses the boundary as JSON. That is slower than a packed binary
25//! layout, deliberately: the boundary stays inspectable, a mismatch produces a
26//! legible error rather than a misread integer, and the volume is low because
27//! bulk data does not cross it. Revisit only if profiling says to.
28//!
29//! # Why bulk data does not cross this boundary
30//!
31//! No command hands a block the contents of a file. A block [`Command::Open`]s a
32//! path, receives a [`Handle`] and a length, then pulls bounded windows with
33//! [`Command::Slice`].
34//!
35//! This keeps guest memory proportional to the window a block chooses rather
36//! than to the size of its input. A block written against a small file behaves
37//! identically against a huge one, and the 4 GiB ceiling of 32-bit wasm stops
38//! being something block authors must reason about — which is what lets this
39//! project stay on `wasm32` instead of paying for `wasm64`.
40
41#![forbid(unsafe_code)]
42#![warn(missing_docs)]
43
44use serde::{Deserialize, Serialize};
45
46/// A job-scoped reference to something the host holds open for a guest.
47///
48/// Job-scoping is a security property, not bookkeeping. A handle table lives and
49/// dies with a single job, so a handle from one job names nothing in another.
50/// That is why [`Command::Slice`] carries no path and needs no capability check
51/// of its own: the check happened once, at [`Command::Open`], and a handle
52/// cannot be forged into a reference to another job's data.
53pub type Handle = u32;
54
55/// The shape of a value flowing through a pipeline.
56///
57/// Deliberately small. This exists to catch the mistake that actually happens
58/// when blocks are composed — one block emitting a summary string into another
59/// expecting a list of chunks — not to be a general-purpose type system. A
60/// richer one would need inference, and inference over a language with no
61/// expressions is machinery without a use.
62/// Written and read as a compact string — `text`, `[text]`, `{path: text}` —
63/// rather than as a nested tagged object.
64///
65/// Two reasons, and the second is the one that forced it. It reads well in an
66/// error message and in a spec, so one syntax serves the wire, the diagnostics,
67/// and the DSL. And a recursive enum serialized structurally makes serde's
68/// generic serializer recurse deeply enough to blow rustc's recursion limit in
69/// the *guest* crate — which would have meant every block author adding
70/// `#![recursion_limit]` to work around a detail of this type.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Ty {
73 /// A UTF-8 string.
74 Text,
75 /// Opaque bytes, base64-encoded on the wire.
76 Bytes,
77 /// A handle naming an image the host holds.
78 Image,
79 /// A handle naming a paged document.
80 Document,
81 /// Any JSON value. The top type: everything is assignable to it.
82 ///
83 /// An escape hatch, and one worth using sparingly — a pipeline of `Json`
84 /// seams typechecks unconditionally, which is the same as not checking.
85 Json,
86 /// An ordered sequence.
87 List(Box<Ty>),
88 /// A fixed set of named fields.
89 ///
90 /// A `BTreeMap` so that two records written in different field orders are
91 /// the same type, and so error messages list fields the same way twice.
92 Record(std::collections::BTreeMap<String, Ty>),
93}
94
95impl Ty {
96 /// Whether a value of this type can be fed where `expected` is required.
97 ///
98 /// Not equality: [`Ty::Json`] accepts anything, and a record with *extra*
99 /// fields satisfies one that needs fewer. Both directions of that matter —
100 /// a block that adds a field should not break its consumer, and a block
101 /// that requires a field its producer never emits should fail loudly.
102 pub fn assignable_to(&self, expected: &Ty) -> bool {
103 match (self, expected) {
104 (_, Ty::Json) => true,
105 (Ty::List(a), Ty::List(b)) => a.assignable_to(b),
106 (Ty::Record(have), Ty::Record(need)) => need
107 .iter()
108 .all(|(name, want)| have.get(name).is_some_and(|got| got.assignable_to(want))),
109 (a, b) => a == b,
110 }
111 }
112
113 /// Whether a live JSON value could plausibly be an instance of this
114 /// type — a runtime counterpart to [`Self::assignable_to`], which only
115 /// ever compares two declared `Ty`s against each other. Nothing in the
116 /// host checked a block's *actual* output against what it declared
117 /// until this existed: a block could claim `{summary: text}` and return
118 /// `{text: "..."}` and nothing downstream would notice until whatever
119 /// consumed `summary` got `null`.
120 ///
121 /// Deliberately permissive, not a full validator: [`Ty::Bytes`],
122 /// [`Ty::Image`], and [`Ty::Document`] have no fixed JSON shape defined
123 /// anywhere in this protocol (unlike [`Ty::Text`]/[`Ty::List`]/
124 /// [`Ty::Record`], which map onto JSON strings/arrays/objects
125 /// unambiguously) — inventing a shape for them here risks rejecting
126 /// legitimate values a real block already produces. Those three, like
127 /// [`Ty::Json`], accept anything.
128 pub fn matches_value(&self, value: &serde_json::Value) -> bool {
129 match self {
130 Ty::Json | Ty::Bytes | Ty::Image | Ty::Document => true,
131 Ty::Text => value.is_string(),
132 Ty::List(inner) => value
133 .as_array()
134 .is_some_and(|items| items.iter().all(|v| inner.matches_value(v))),
135 Ty::Record(fields) => value.as_object().is_some_and(|obj| {
136 fields
137 .iter()
138 .all(|(name, want)| obj.get(name).is_some_and(|v| want.matches_value(v)))
139 }),
140 }
141 }
142
143 /// A short human-readable rendering, for error messages.
144 pub fn describe(&self) -> String {
145 match self {
146 Ty::Text => "text".into(),
147 Ty::Bytes => "bytes".into(),
148 Ty::Image => "image".into(),
149 Ty::Document => "document".into(),
150 Ty::Json => "json".into(),
151 Ty::List(inner) => format!("[{}]", inner.describe()),
152 Ty::Record(fields) => {
153 let body = fields
154 .iter()
155 .map(|(k, v)| format!("{k}: {}", v.describe()))
156 .collect::<Vec<_>>()
157 .join(", ");
158 format!("{{{body}}}")
159 }
160 }
161 }
162}
163
164impl std::fmt::Display for Ty {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str(&self.describe())
167 }
168}
169
170impl std::str::FromStr for Ty {
171 type Err = String;
172
173 fn from_str(s: &str) -> Result<Self, Self::Err> {
174 parse_ty(s.trim())
175 }
176}
177
178impl Serialize for Ty {
179 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
180 s.serialize_str(&self.describe())
181 }
182}
183
184impl<'de> Deserialize<'de> for Ty {
185 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
186 let raw = String::deserialize(d)?;
187 raw.parse().map_err(serde::de::Error::custom)
188 }
189}
190
191/// Parse the type syntax [`Ty::describe`] produces.
192fn parse_ty(s: &str) -> Result<Ty, String> {
193 let s = s.trim();
194 match s {
195 "text" => return Ok(Ty::Text),
196 "bytes" => return Ok(Ty::Bytes),
197 "image" => return Ok(Ty::Image),
198 "document" => return Ok(Ty::Document),
199 "json" => return Ok(Ty::Json),
200 _ => {}
201 }
202
203 if let Some(inner) = s.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
204 return Ok(Ty::List(Box::new(parse_ty(inner)?)));
205 }
206
207 if let Some(body) = s.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
208 let mut fields = std::collections::BTreeMap::new();
209 if !body.trim().is_empty() {
210 for part in split_fields(body) {
211 let (name, ty) = part
212 .split_once(':')
213 .ok_or_else(|| format!("expected `name: type` in `{part}`"))?;
214 fields.insert(name.trim().to_string(), parse_ty(ty)?);
215 }
216 }
217 return Ok(Ty::Record(fields));
218 }
219
220 Err(format!("`{s}` is not a type"))
221}
222
223/// Split record fields on commas that are not inside a nested `[]` or `{}`.
224///
225/// A plain `split(',')` would cut `{a: [x, y]}` in the wrong place.
226fn split_fields(body: &str) -> Vec<String> {
227 let (mut out, mut depth, mut current) = (Vec::new(), 0i32, String::new());
228 for c in body.chars() {
229 match c {
230 '[' | '{' => {
231 depth += 1;
232 current.push(c);
233 }
234 ']' | '}' => {
235 depth -= 1;
236 current.push(c);
237 }
238 ',' if depth == 0 => out.push(std::mem::take(&mut current)),
239 _ => current.push(c),
240 }
241 }
242 if !current.trim().is_empty() {
243 out.push(current);
244 }
245 out
246}
247
248/// What a block accepts and produces.
249///
250/// Declared by the block itself, through a `cf_signature` export, rather than in
251/// a sidecar file beside it. A sidecar can disagree with the code it describes
252/// and nothing forces anyone to notice; a declaration compiled into the module
253/// travels with it, cannot go stale, and leaves one artifact to ship rather than
254/// two to keep in step.
255///
256/// `Display`/`FromStr` render and parse it as `"{input} -> {output}"` —
257/// each side is a [`Ty`], and this is the compact form the catalog caches
258/// and a bundle manifest embeds. Splitting on `" -> "` is unambiguous only
259/// because `Ty::describe()` never produces that substring itself.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct Signature {
262 /// What the block needs as input.
263 pub input: Ty,
264 /// What it produces.
265 pub output: Ty,
266}
267
268impl std::fmt::Display for Signature {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 write!(f, "{} -> {}", self.input, self.output)
271 }
272}
273
274impl std::str::FromStr for Signature {
275 type Err = String;
276
277 fn from_str(s: &str) -> Result<Self, Self::Err> {
278 let (input, output) = s
279 .split_once(" -> ")
280 .ok_or_else(|| format!("`{s}` is not a signature (expected `input -> output`)"))?;
281 Ok(Signature {
282 input: input.parse()?,
283 output: output.parse()?,
284 })
285 }
286}
287
288/// What kind of thing a handle refers to, reported by [`Event::Opened`].
289///
290/// A block needs this to know which commands are worth issuing: [`Command::Slice`]
291/// on a PNG is a mistake, and [`Command::PageText`] on a plain text file is
292/// meaningless. Reporting it up front means a block can branch on what it
293/// actually got rather than guessing from a file extension.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(tag = "kind", rename_all = "snake_case")]
296pub enum MediaKind {
297 /// Valid UTF-8. Both [`Command::Slice`] and [`Command::SliceBytes`] work.
298 Text,
299 /// An image the host recognised. Usable as an [`Command::Infer`] image.
300 Image {
301 /// Format as detected from content, e.g. `png`, `jpeg`.
302 format: String,
303 },
304 /// A paged document — a PDF, say.
305 Document {
306 /// How many pages it has.
307 pages: u32,
308 /// Whether it carries an extractable text layer.
309 ///
310 /// False for a scanned document, where the only way to read it is to
311 /// rasterize pages and hand them to a vision model. A block that checks
312 /// this can pick the cheap path when it exists and the expensive one
313 /// when it must, instead of silently extracting nothing.
314 has_text_layer: bool,
315 },
316 /// Bytes the host could not classify. Only [`Command::SliceBytes`] applies.
317 Binary,
318}
319
320/// What a guest asks the host to do, returned from its `init`/`step` exports.
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
322#[serde(tag = "cmd", rename_all = "snake_case")]
323pub enum Command {
324 /// Run a prompt against the job's model.
325 Infer {
326 /// The prompt to generate from.
327 prompt: String,
328 /// Upper bound on tokens generated. A guest can also end generation
329 /// early by returning [`TokenAction::Stop`] from its `on_token` export.
330 max_tokens: u32,
331 /// Images to accompany the prompt, named by handle.
332 ///
333 /// Handles rather than bytes, for the same reason file contents are not
334 /// handed over: an image can be tens of megabytes, and routing it
335 /// through guest memory would put the 4 GiB wasm32 ceiling back in play
336 /// for no benefit. The host already holds the bytes; it can pass them to
337 /// the model directly.
338 ///
339 /// Requires a model with vision capability. Empty for ordinary
340 /// text-only inference, which is why it is `#[serde(default)]` — a block
341 /// compiled before this field existed still deserializes.
342 #[serde(default)]
343 images: Vec<Handle>,
344 },
345 /// Open a file. Capability-checked against the job's spec.
346 ///
347 /// Yields a handle and a length rather than contents — see the crate docs on
348 /// why bulk data does not cross this boundary.
349 Open {
350 /// Path to open. Denied unless the spec grants read access to it.
351 path: String,
352 },
353 /// Pull one bounded window of an open file into guest memory.
354 ///
355 /// The guest picks `len`, so the guest sets its own memory ceiling.
356 Slice {
357 /// Handle from a previous [`Command::Open`].
358 handle: Handle,
359 /// Byte offset to read from. `u64` so that files far larger than a guest
360 /// could hold remain fully addressable.
361 offset: u64,
362 /// Maximum bytes to return. The host may return fewer; see
363 /// [`Event::Sliced`].
364 len: u64,
365 },
366 /// Pull one bounded window of an open file as raw bytes.
367 ///
368 /// The binary counterpart to [`Command::Slice`]. Prefer `Slice` for text:
369 /// it needs no encoding, and it handles the character-boundary problem for
370 /// you. This exists for blocks that genuinely need bytes — inspecting an
371 /// image header, say — and pays base64's cost to carry them.
372 SliceBytes {
373 /// Handle from a previous [`Command::Open`].
374 handle: Handle,
375 /// Byte offset to read from.
376 offset: u64,
377 /// Maximum bytes to return.
378 len: u64,
379 },
380 /// Extract one page of a document as text.
381 ///
382 /// Fails when the document has no text layer; check
383 /// [`MediaKind::Document::has_text_layer`] first.
384 PageText {
385 /// Handle from a previous [`Command::Open`].
386 handle: Handle,
387 /// Zero-based page number.
388 page: u32,
389 },
390 /// Render one page of a document to an image.
391 ///
392 /// Yields a *new* handle referring to the rendered image, which can then be
393 /// named in [`Command::Infer`]. That indirection is deliberate: the image
394 /// stays host-side like every other bulk value, and a rendered page is
395 /// usable exactly wherever a file-backed image is.
396 PageImage {
397 /// Handle from a previous [`Command::Open`].
398 handle: Handle,
399 /// Zero-based page number.
400 page: u32,
401 },
402 /// Report progress to whoever is watching the job's event stream.
403 Emit {
404 /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
405 progress: serde_json::Value,
406 },
407 /// Finish successfully with this payload.
408 Done {
409 /// The job's result, shaped by the spec's declared output.
410 result: serde_json::Value,
411 },
412 /// Give up. The job ends with this code and message, and no result.
413 Fail {
414 /// Machine-readable code; see [`error_codes`].
415 code: String,
416 /// Human-readable explanation.
417 message: String,
418 },
419}
420
421/// What the host feeds back into the guest's `step` export after carrying out a
422/// [`Command`].
423#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
424#[serde(tag = "event", rename_all = "snake_case")]
425pub enum Event {
426 /// Generation finished.
427 InferDone {
428 /// The generated text.
429 text: String,
430 /// How many tokens were produced. May be fewer than the requested
431 /// `max_tokens` if the guest ended generation early.
432 tokens_out: u32,
433 },
434 /// A file was opened.
435 Opened {
436 /// Use this in subsequent [`Command::Slice`] calls.
437 handle: Handle,
438 /// Total size of the file, in bytes.
439 len: u64,
440 /// What the host made of the contents; see [`MediaKind`].
441 ///
442 /// `#[serde(default)]` so a block built before this field existed still
443 /// deserializes, treating anything it opens as text.
444 #[serde(default)]
445 kind: MediaKind,
446 },
447 /// A window of a file was read.
448 Sliced {
449 /// The window's contents.
450 text: String,
451 /// Where the returned text actually ended.
452 ///
453 /// This is **not** always `offset + len` from the request: the host cuts
454 /// a window back to a UTF-8 character boundary, because a caller picking
455 /// window sizes has no idea where characters begin, and a naive split
456 /// would corrupt a multi-byte character at nearly every seam. A guest
457 /// walking a file must resume from this value rather than advancing by
458 /// the length it asked for.
459 next_offset: u64,
460 },
461 /// A window of a file was read as raw bytes.
462 SlicedBytes {
463 /// The window's contents, base64-encoded.
464 ///
465 /// Base64 rather than a binary side channel: the boundary is JSON, and
466 /// keeping it inspectable is worth more than the third it costs on a
467 /// path blocks are not expected to use in bulk.
468 bytes_base64: String,
469 /// Where the returned bytes ended. Unlike [`Event::Sliced`] there is no
470 /// truncation, so this is always `offset + len` clamped to the file.
471 next_offset: u64,
472 },
473 /// A document page was extracted as text.
474 PageTexted {
475 /// The page's text.
476 text: String,
477 },
478 /// A document page was rendered to an image.
479 PageImaged {
480 /// A new handle referring to the rendered image; name it in
481 /// [`Command::Infer`].
482 handle: Handle,
483 /// Its size in bytes.
484 len: u64,
485 },
486 /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
487 /// and the command loop keeps its shape.
488 Emitted,
489}
490
491/// A guest's verdict on each streamed token, returned from its `on_token`
492/// export.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum TokenAction {
495 /// Keep generating.
496 Continue,
497 /// Stop generating now.
498 ///
499 /// A token or two may still arrive after this, because the verdict has to
500 /// travel back to the thread doing the generating.
501 Stop,
502}
503
504impl TokenAction {
505 /// Decode the raw `i32` a guest's `on_token` export returns.
506 ///
507 /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
508 /// returning a value this crate does not recognise is malfunctioning, and
509 /// the safe reading of a malfunctioning guest is "stop", never "keep
510 /// spending tokens" — the same fail-closed posture as the capability checks.
511 pub fn from_i32(v: i32) -> Self {
512 if v == 0 {
513 Self::Continue
514 } else {
515 Self::Stop
516 }
517 }
518
519 /// Encode for the wasm boundary.
520 ///
521 /// These integers are part of the ABI: renumbering them silently changes the
522 /// meaning of every already-compiled block.
523 pub fn as_i32(self) -> i32 {
524 match self {
525 Self::Continue => 0,
526 Self::Stop => 1,
527 }
528 }
529}
530
531/// Where a job is in its lifecycle.
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum JobStatus {
535 /// Accepted, not yet started.
536 Queued,
537 /// Executing.
538 Running,
539 /// Finished with a result.
540 Completed,
541 /// Finished with an error and no result.
542 Failed,
543 /// Stopped by request.
544 Cancelled,
545 /// Was running when the daemon last stopped; not resumed automatically.
546 Interrupted,
547}
548
549impl JobStatus {
550 /// Whether this status is final — nothing further will happen to the job.
551 ///
552 /// Clients poll until this is true. Adding a new non-terminal status is
553 /// therefore safe, while a new terminal one that is missing from this match
554 /// leaves callers waiting forever.
555 pub fn is_terminal(self) -> bool {
556 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
557 }
558}
559
560/// What a job cost.
561#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
562pub struct Usage {
563 /// Tokens consumed by prompts.
564 pub tokens_in: u32,
565 /// Tokens generated.
566 pub tokens_out: u32,
567 /// Wall-clock duration of the job.
568 pub duration_ms: u64,
569 /// Which model served the job's inference.
570 pub model: String,
571}
572
573/// The fixed, spec-independent envelope handed back to the calling agent.
574///
575/// Every job returns this shape regardless of what it did, so an agent can
576/// handle results without knowing anything about the block that produced them.
577/// Only `result` varies, and its shape is that job's business.
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
579pub struct Envelope {
580 /// Lifecycle state; see [`JobStatus::is_terminal`].
581 pub status: JobStatus,
582 /// Present only when the job completed.
583 ///
584 /// A failed or cancelled job never carries a partial result: a caller must
585 /// never have to guess whether a payload is trustworthy.
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub result: Option<serde_json::Value>,
588 /// Present only when the job failed or was cancelled.
589 #[serde(skip_serializing_if = "Option::is_none")]
590 pub error: Option<JobError>,
591 /// Cost accounting, populated even for failed jobs — work already spent
592 /// still counts.
593 pub usage: Usage,
594}
595
596/// Why a job did not complete.
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
598pub struct JobError {
599 /// Machine-readable; see [`error_codes`].
600 pub code: String,
601 /// Human-readable detail.
602 pub message: String,
603}
604
605/// The error codes the daemon emits in [`JobError::code`].
606///
607/// These are string constants rather than an enum so the set can grow without
608/// breaking clients that match on strings, and so a client built against an
609/// older version meets an unfamiliar code rather than a decode failure.
610pub mod error_codes {
611 /// The job's model could not be loaded or served.
612 pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
613 /// A guest tried to reach something its spec does not grant.
614 pub const CAPABILITY_DENIED: &str = "capability_denied";
615 /// Job input did not match the spec's declared shape.
616 pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
617 /// A Script-kind block's own logic failed at run time — a Rhai runtime
618 /// error (e.g. an out-of-bounds index, a missing function) surfacing
619 /// from inside the script itself. `catalog add` rejects a script whose
620 /// body doesn't *parse*, so this is specifically a failure that only
621 /// shows up once the script actually runs; it is not a schema mismatch,
622 /// so it does not reuse [`SCHEMA_VALIDATION_FAILED`].
623 pub const SCRIPT_ERROR: &str = "script_error";
624 /// The guest trapped — a panic, a bad export signature, or malformed wasm.
625 pub const WASM_TRAP: &str = "wasm_trap";
626 /// The job exceeded its time budget.
627 pub const TIMEOUT: &str = "timeout";
628 /// The job was cancelled by request.
629 pub const CANCELLED: &str = "cancelled";
630 /// A command needed a capability this build does not have — asking for a
631 /// page image without document rendering compiled in, say.
632 pub const UNSUPPORTED: &str = "unsupported";
633}
634
635impl Default for MediaKind {
636 /// Text, because that is what every command predating [`MediaKind`]
637 /// assumed, and because it keeps an older block's behaviour unchanged.
638 fn default() -> Self {
639 Self::Text
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn a_signature_round_trips_through_its_compact_string() {
649 let sig = Signature {
650 input: Ty::Record([("path".to_string(), Ty::Text)].into_iter().collect()),
651 output: Ty::List(Box::new(Ty::Text)),
652 };
653 let s = sig.to_string();
654 assert_eq!(s, "{path: text} -> [text]");
655 assert_eq!(s.parse::<Signature>().unwrap(), sig);
656 }
657
658 #[test]
659 fn a_signature_without_an_arrow_is_rejected() {
660 assert!("just-a-type".parse::<Signature>().is_err());
661 }
662
663 #[test]
664 fn a_signature_with_an_unparseable_side_is_rejected() {
665 assert!("text -> not a type".parse::<Signature>().is_err());
666 }
667
668 #[test]
669 fn text_matches_a_json_string_only() {
670 assert!(Ty::Text.matches_value(&serde_json::json!("hello")));
671 assert!(!Ty::Text.matches_value(&serde_json::json!(42)));
672 }
673
674 #[test]
675 fn json_matches_anything() {
676 assert!(Ty::Json.matches_value(&serde_json::json!(null)));
677 assert!(Ty::Json.matches_value(&serde_json::json!([1, "two", {}])));
678 }
679
680 #[test]
681 fn a_record_missing_a_declared_field_does_not_match() {
682 let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
683 assert!(!ty.matches_value(&serde_json::json!({"text": "hello world"})));
684 }
685
686 #[test]
687 fn a_record_with_the_declared_field_present_and_well_typed_matches() {
688 let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
689 assert!(ty.matches_value(&serde_json::json!({"summary": "hi", "extra": 1})));
690 }
691
692 #[test]
693 fn a_record_with_a_wrong_typed_declared_field_does_not_match() {
694 let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
695 assert!(!ty.matches_value(&serde_json::json!({"summary": 42})));
696 }
697
698 #[test]
699 fn a_list_matches_only_when_every_element_matches_the_inner_type() {
700 let ty = Ty::List(Box::new(Ty::Text));
701 assert!(ty.matches_value(&serde_json::json!(["a", "b"])));
702 assert!(!ty.matches_value(&serde_json::json!(["a", 2])));
703 assert!(!ty.matches_value(&serde_json::json!("not a list")));
704 }
705
706 #[test]
707 fn bytes_image_and_document_accept_anything() {
708 for ty in [Ty::Bytes, Ty::Image, Ty::Document] {
709 assert!(ty.matches_value(&serde_json::json!(123)));
710 }
711 }
712}