Skip to main content

ag_ui_a2ui/toolkit/
schema.rs

1//! The three JSON Schema documents that describe a surface, and surgery on them.
2//!
3//! A2UI v0.9 splits its schema into three files that travel together:
4//!
5//! - **server-to-client** — the message envelopes;
6//! - **common types** — `ComponentId`, `ChildList`, the `Dynamic*` bindings;
7//! - **catalog** — the components and functions themselves.
8//!
9//! [`SchemaBundle`] holds all three, because everything you do with one you do
10//! with all three: put them in a prompt, restrict them to a subset of
11//! components, hand them to a validator.
12//!
13//! # Why prune
14//!
15//! The whole point of the v0.9 "prompt-first" design is that the schema goes
16//! into the model's context. That context is finite, and an agent usually wants
17//! the model to use ten components rather than the catalog's eighty. Pruning is
18//! how you say so: [`SchemaBundle::prune`] keeps the components (or messages)
19//! you allow and then walks `$ref`s to drop every type that nothing kept still
20//! refers to — including transitively, so a shared type survives exactly as long
21//! as something still points at it.
22
23use std::collections::BTreeSet;
24use std::path::{Path, PathBuf};
25
26use serde_json::{Map, Value};
27
28use crate::error::{Error, Result};
29use crate::toolkit::streaming::DEFAULT_CUTTABLE_KEYS;
30
31/// Markers around the schema block in a generated prompt.
32///
33/// Fixed strings rather than prose: the reference toolkits emit exactly these,
34/// and evaluation harnesses key on them to find the block.
35pub const SCHEMA_BLOCK_START: &str = "---BEGIN A2UI JSON SCHEMA---";
36/// Closing marker of the prompt's schema block.
37pub const SCHEMA_BLOCK_END: &str = "---END A2UI JSON SCHEMA---";
38
39/// The three schema documents describing one surface contract.
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct SchemaBundle {
42    /// The server-to-client envelope schema.
43    pub s2c: Value,
44    /// Shared type definitions the other two reference.
45    pub common_types: Value,
46    /// The component and function catalog.
47    pub catalog: Value,
48    /// Keys whose string values may be closed early while streaming.
49    ///
50    /// `None` uses [`DEFAULT_CUTTABLE_KEYS`]. Override it when a catalog puts
51    /// long free text under a name the defaults do not cover.
52    pub custom_cuttable_keys: Option<Vec<String>>,
53}
54
55impl SchemaBundle {
56    /// A bundle holding only a catalog document.
57    pub fn from_catalog(catalog: Value) -> Self {
58        Self {
59            catalog,
60            ..Self::default()
61        }
62    }
63
64    /// The `catalogId` this bundle's catalog declares.
65    pub fn catalog_id(&self) -> Option<&str> {
66        self.catalog.get("catalogId").and_then(Value::as_str)
67    }
68
69    /// The keys whose string values may be closed early while streaming.
70    pub fn cuttable_keys(&self) -> Vec<String> {
71        match &self.custom_cuttable_keys {
72            Some(keys) => keys.clone(),
73            None => DEFAULT_CUTTABLE_KEYS
74                .iter()
75                .map(|key| (*key).to_string())
76                .collect(),
77        }
78    }
79
80    /// Renders the bundle as the schema block of a prompt.
81    ///
82    /// The common-types section is dropped when it has nothing to say — no
83    /// `$defs`, or an empty one — so the model is not handed an empty document
84    /// to reason about.
85    pub fn render_llm_instructions(&self) -> String {
86        let mut sections = vec![SCHEMA_BLOCK_START.to_string()];
87        sections.push(format!(
88            "### Server To Client Schema:\n{}",
89            compact(&self.s2c)
90        ));
91        if has_defs(&self.common_types) {
92            sections.push(format!(
93                "### Common Types Schema:\n{}",
94                compact(&self.common_types)
95            ));
96        }
97        sections.push(format!("### Catalog Schema:\n{}", compact(&self.catalog)));
98        sections.push(SCHEMA_BLOCK_END.to_string());
99        sections.join("\n\n")
100    }
101
102    /// Restricts the bundle to a subset of components and message types.
103    ///
104    /// Either list may be empty, meaning "keep everything of that kind". After
105    /// the restriction, unreferenced type definitions are dropped from both the
106    /// envelope schema and the common types.
107    #[must_use]
108    pub fn prune(mut self, allowed_components: &[String], allowed_messages: &[String]) -> Self {
109        if !allowed_components.is_empty() {
110            prune_components(&mut self.catalog, allowed_components);
111        }
112        if !allowed_messages.is_empty() {
113            prune_messages(&mut self.s2c, allowed_messages);
114        }
115        // Common types are shared, so what survives depends on what the other
116        // two documents still reference.
117        prune_common_types(&mut self.common_types, &[&self.catalog, &self.s2c]);
118        self
119    }
120
121    /// Drops `additionalProperties: false` everywhere in the catalog.
122    ///
123    /// Structured-output APIs reject schemas that forbid extra properties in
124    /// places they need them; explicit `true` is left alone because that is a
125    /// deliberate statement rather than a default.
126    #[must_use]
127    pub fn relaxed(mut self) -> Self {
128        remove_strict_validation(&mut self.s2c);
129        remove_strict_validation(&mut self.common_types);
130        remove_strict_validation(&mut self.catalog);
131        self
132    }
133}
134
135fn compact(value: &Value) -> String {
136    serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
137}
138
139fn has_defs(schema: &Value) -> bool {
140    schema
141        .get("$defs")
142        .and_then(Value::as_object)
143        .is_some_and(|defs| !defs.is_empty())
144}
145
146/// Keeps only the named components, and the `anyComponent` branches that reach
147/// them.
148pub(crate) fn prune_components(catalog: &mut Value, allowed: &[String]) {
149    let allowed: BTreeSet<&str> = allowed.iter().map(String::as_str).collect();
150    let Some(object) = catalog.as_object_mut() else {
151        return;
152    };
153    if let Some(Value::Object(components)) = object.get_mut("components") {
154        components.retain(|name, _| allowed.contains(name.as_str()));
155    }
156    // The catalog's discriminated union must lose the same branches, or it
157    // still admits components the document no longer defines.
158    if let Some(Value::Object(defs)) = object.get_mut("$defs") {
159        if let Some(Value::Object(any_component)) = defs.get_mut("anyComponent") {
160            for key in ["oneOf", "anyOf"] {
161                if let Some(Value::Array(branches)) = any_component.get_mut(key) {
162                    branches.retain(|branch| match branch.get("$ref").and_then(Value::as_str) {
163                        Some(reference) => reference
164                            .rsplit('/')
165                            .next()
166                            .is_some_and(|name| allowed.contains(name)),
167                        None => true,
168                    });
169                }
170            }
171        }
172    }
173}
174
175/// Keeps only the named messages, then drops definitions nothing reaches.
176///
177/// Handles both envelope shapes: a `oneOf` of `$ref`s, and a flat `properties`
178/// map keyed by message name.
179pub(crate) fn prune_messages(s2c: &mut Value, allowed: &[String]) {
180    let allowed: BTreeSet<&str> = allowed.iter().map(String::as_str).collect();
181    let Some(object) = s2c.as_object_mut() else {
182        return;
183    };
184
185    let mut pruned_union = false;
186    for key in ["oneOf", "anyOf"] {
187        if let Some(Value::Array(branches)) = object.get_mut(key) {
188            branches.retain(|branch| match branch.get("$ref").and_then(Value::as_str) {
189                Some(reference) => reference
190                    .rsplit('/')
191                    .next()
192                    .is_some_and(|name| allowed.contains(name)),
193                None => true,
194            });
195            pruned_union = true;
196        }
197    }
198    if let Some(Value::Object(properties)) = object.get_mut("properties") {
199        if !pruned_union {
200            properties.retain(|name, _| allowed.contains(name.as_str()));
201        }
202    }
203
204    if pruned_union {
205        let roots: Vec<Value> = object
206            .iter()
207            .filter(|(key, _)| *key != "$defs")
208            .map(|(_, value)| value.clone())
209            .collect();
210        let roots: Vec<&Value> = roots.iter().collect();
211        retain_reachable_defs(object, &roots, "#/$defs/");
212    }
213}
214
215/// Keeps only the common types still referenced by the given documents.
216pub(crate) fn prune_common_types(common_types: &mut Value, referenced_by: &[&Value]) {
217    let Some(object) = common_types.as_object_mut() else {
218        return;
219    };
220    if object.get("$defs").and_then(Value::as_object).is_none() {
221        return;
222    }
223    retain_reachable_defs(object, referenced_by, "common_types.json#/$defs/");
224}
225
226/// Retains `$defs` entries reachable from `roots`, following refs transitively.
227///
228/// `external_prefix` is how another document names these definitions; the
229/// document's own `#/$defs/` form is always followed as well.
230fn retain_reachable_defs(object: &mut Map<String, Value>, roots: &[&Value], external_prefix: &str) {
231    let Some(defs) = object.get("$defs").and_then(Value::as_object).cloned() else {
232        return;
233    };
234
235    let mut keep: BTreeSet<String> = BTreeSet::new();
236    let mut queue: Vec<Value> = roots.iter().map(|root| (*root).clone()).collect();
237
238    while let Some(value) = queue.pop() {
239        for reference in collect_refs(&value) {
240            let name = reference
241                .strip_prefix(external_prefix)
242                .or_else(|| reference.strip_prefix("#/$defs/"))
243                .map(str::to_string);
244            let Some(name) = name else { continue };
245            if !defs.contains_key(&name) || !keep.insert(name.clone()) {
246                continue;
247            }
248            if let Some(definition) = defs.get(&name) {
249                queue.push(definition.clone());
250            }
251        }
252    }
253
254    if let Some(Value::Object(target)) = object.get_mut("$defs") {
255        target.retain(|name, _| keep.contains(name));
256    }
257}
258
259/// Every `$ref` string anywhere inside a value.
260fn collect_refs(value: &Value) -> Vec<String> {
261    let mut out = Vec::new();
262    walk_refs(value, &mut out);
263    out
264}
265
266fn walk_refs(value: &Value, out: &mut Vec<String>) {
267    match value {
268        Value::Object(map) => {
269            for (key, child) in map {
270                if key == "$ref" {
271                    if let Some(reference) = child.as_str() {
272                        out.push(reference.to_string());
273                    }
274                }
275                walk_refs(child, out);
276            }
277        }
278        Value::Array(items) => {
279            for item in items {
280                walk_refs(item, out);
281            }
282        }
283        _ => {}
284    }
285}
286
287/// Removes every `additionalProperties: false`, recursively.
288pub fn remove_strict_validation(schema: &mut Value) {
289    match schema {
290        Value::Object(map) => {
291            if map.get("additionalProperties") == Some(&Value::Bool(false)) {
292                map.remove("additionalProperties");
293            }
294            for child in map.values_mut() {
295                remove_strict_validation(child);
296            }
297        }
298        Value::Array(items) => {
299            for item in items {
300                remove_strict_validation(item);
301            }
302        }
303        _ => {}
304    }
305}
306
307/// Loads few-shot examples for a prompt.
308///
309/// `path` is a directory (every `*.json` inside it) or a glob pattern. Each
310/// example is wrapped in `---BEGIN <stem>--- / ---END <stem>---` markers so the
311/// model can tell where one ends and the next begins, and files are read in
312/// sorted order so the prompt is byte-stable across runs.
313///
314/// A path that matches nothing yields an empty string: examples are an
315/// optimization, and a missing directory should not take an agent down.
316///
317/// # Errors
318///
319/// With `validate` set, returns [`Error::Catalog`] if an example is not valid
320/// JSON.
321pub fn load_examples(path: Option<&Path>, validate: bool) -> Result<String> {
322    let Some(path) = path else {
323        return Ok(String::new());
324    };
325    let mut matches = if path.is_dir() {
326        collect_matches(path, "*.json")
327    } else {
328        let pattern = path.to_string_lossy().to_string();
329        let (base, pattern) = split_pattern(&pattern);
330        collect_matches(&base, &pattern)
331    };
332    matches.sort();
333
334    let mut blocks = Vec::new();
335    for file in matches {
336        if !file.is_file() {
337            continue;
338        }
339        let stem = file
340            .file_stem()
341            .map(|stem| stem.to_string_lossy().to_string())
342            .unwrap_or_default();
343        let content = std::fs::read_to_string(&file).map_err(|e| {
344            Error::catalog(format!("Failed to read example {}: {e}", file.display()))
345        })?;
346        if validate {
347            serde_json::from_str::<Value>(&content).map_err(|e| {
348                Error::catalog(format!(
349                    "Failed to validate example {}: {e}",
350                    file.display()
351                ))
352            })?;
353        }
354        blocks.push(format!("---BEGIN {stem}---\n{content}\n---END {stem}---"));
355    }
356    Ok(blocks.join("\n\n"))
357}
358
359/// Splits a glob pattern into its fixed base directory and the pattern tail.
360///
361/// Works on the string rather than on `PathBuf`, so a leading `/` survives:
362/// pushing the empty first segment of an absolute path onto a `PathBuf` would
363/// silently make it relative.
364fn split_pattern(pattern: &str) -> (PathBuf, String) {
365    let segments: Vec<&str> = pattern.split('/').collect();
366    let first_wildcard = segments
367        .iter()
368        .position(|segment| segment.contains(['*', '?', '[']));
369
370    let split_at = match first_wildcard {
371        Some(index) => index,
372        // No wildcard: the last segment is the name to match.
373        None => segments.len().saturating_sub(1),
374    };
375    let base = segments[..split_at].join("/");
376    let base = match base.as_str() {
377        "" if pattern.starts_with('/') => PathBuf::from("/"),
378        "" => PathBuf::from("."),
379        other => PathBuf::from(other),
380    };
381    (base, segments[split_at..].join("/"))
382}
383
384/// Walks `base` collecting files matching `pattern`.
385fn collect_matches(base: &Path, pattern: &str) -> Vec<PathBuf> {
386    let recursive = pattern.contains("**");
387    let mut out = Vec::new();
388    let mut stack = vec![base.to_path_buf()];
389
390    while let Some(dir) = stack.pop() {
391        let Ok(entries) = std::fs::read_dir(&dir) else {
392            continue;
393        };
394        for entry in entries.flatten() {
395            let path = entry.path();
396            if path.is_dir() {
397                if recursive {
398                    stack.push(path);
399                }
400                continue;
401            }
402            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy();
403            if glob_match(pattern, &relative) {
404                out.push(path);
405            }
406        }
407    }
408    out
409}
410
411/// Matches a path against a glob pattern.
412///
413/// Supports `*`, `?`, character classes with ranges and `!` negation, and `**`
414/// for "any number of directories". `*` alone never crosses a `/`, which is
415/// what keeps `dir/*.json` from reaching into subdirectories.
416pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
417    // `**/` matches zero or more directories, so try it both ways.
418    if let Some(rest) = pattern.strip_prefix("**/") {
419        if glob_match(rest, text) {
420            return true;
421        }
422        if let Some((_, tail)) = text.split_once('/') {
423            return glob_match(pattern, tail);
424        }
425        return false;
426    }
427    match_here(
428        &pattern.chars().collect::<Vec<_>>(),
429        &text.chars().collect::<Vec<_>>(),
430    )
431}
432
433fn match_here(pattern: &[char], text: &[char]) -> bool {
434    let mut p = 0;
435    let mut t = 0;
436    // Backtracking point for the most recent `*`.
437    let mut star: Option<(usize, usize)> = None;
438
439    while t < text.len() {
440        match pattern.get(p) {
441            Some('*') => {
442                star = Some((p, t));
443                p += 1;
444            }
445            Some('?') if text[t] != '/' => {
446                p += 1;
447                t += 1;
448            }
449            Some('[') => match match_class(pattern, p, text[t]) {
450                Some(next) => {
451                    p = next;
452                    t += 1;
453                }
454                None => match retry(&mut star, &mut p, &mut t, text) {
455                    true => continue,
456                    false => return false,
457                },
458            },
459            Some(ch) if *ch == text[t] => {
460                p += 1;
461                t += 1;
462            }
463            _ => {
464                if !retry(&mut star, &mut p, &mut t, text) {
465                    return false;
466                }
467            }
468        }
469    }
470    while pattern.get(p) == Some(&'*') {
471        p += 1;
472    }
473    p == pattern.len()
474}
475
476/// Resumes from the last `*`, consuming one more character.
477fn retry(star: &mut Option<(usize, usize)>, p: &mut usize, t: &mut usize, text: &[char]) -> bool {
478    match star {
479        // A single `*` never matches a path separator.
480        Some((sp, st)) if text[*st] != '/' => {
481            *p = *sp + 1;
482            *st += 1;
483            *t = *st;
484            true
485        }
486        _ => false,
487    }
488}
489
490/// Matches one character class starting at `open`, returning the index after it.
491fn match_class(pattern: &[char], open: usize, ch: char) -> Option<usize> {
492    let mut i = open + 1;
493    let negated = matches!(pattern.get(i), Some('!') | Some('^'));
494    if negated {
495        i += 1;
496    }
497    let mut matched = false;
498    let mut first = true;
499    while i < pattern.len() {
500        if pattern[i] == ']' && !first {
501            return (matched != negated).then_some(i + 1);
502        }
503        first = false;
504        // A range, unless the `-` is the last character before `]`.
505        if pattern.get(i + 1) == Some(&'-') && pattern.get(i + 2).is_some_and(|c| *c != ']') {
506            let (low, high) = (pattern[i], pattern[i + 2]);
507            if low <= ch && ch <= high {
508                matched = true;
509            }
510            i += 3;
511            continue;
512        }
513        if pattern[i] == ch {
514            matched = true;
515        }
516        i += 1;
517    }
518    None
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use serde_json::json;
525
526    #[test]
527    fn the_schema_block_carries_all_three_documents() {
528        let bundle = SchemaBundle {
529            s2c: json!({"s2c": "schema"}),
530            common_types: json!({"$defs": {"common": "types"}}),
531            catalog: json!({"catalogId": "id_basic"}),
532            custom_cuttable_keys: None,
533        };
534        let rendered = bundle.render_llm_instructions();
535        assert_eq!(
536            rendered,
537            "---BEGIN A2UI JSON SCHEMA---\n\n\
538             ### Server To Client Schema:\n{\"s2c\":\"schema\"}\n\n\
539             ### Common Types Schema:\n{\"$defs\":{\"common\":\"types\"}}\n\n\
540             ### Catalog Schema:\n{\"catalogId\":\"id_basic\"}\n\n\
541             ---END A2UI JSON SCHEMA---"
542        );
543    }
544
545    #[test]
546    fn empty_common_types_are_left_out_of_the_block() {
547        for common in [
548            json!({}),
549            json!({"something": "else"}),
550            json!({"$defs": {}}),
551        ] {
552            let bundle = SchemaBundle {
553                s2c: json!({"s2c": "schema"}),
554                common_types: common,
555                catalog: json!({"catalogId": "id"}),
556                custom_cuttable_keys: None,
557            };
558            let rendered = bundle.render_llm_instructions();
559            assert!(!rendered.contains("Common Types Schema"), "{rendered}");
560            assert!(rendered.contains("Server To Client Schema"));
561        }
562    }
563
564    #[test]
565    fn pruning_components_also_prunes_the_union() {
566        let mut catalog = json!({
567            "catalogId": "basic",
568            "$defs": {"anyComponent": {"oneOf": [
569                {"$ref": "#/components/Text"},
570                {"$ref": "#/components/Button"},
571                {"$ref": "#/components/Image"}
572            ]}},
573            "components": {"Text": {}, "Button": {}, "Image": {}}
574        });
575        prune_components(&mut catalog, &["Text".to_string()]);
576        assert_eq!(
577            catalog,
578            json!({
579                "catalogId": "basic",
580                "$defs": {"anyComponent": {"oneOf": [{"$ref": "#/components/Text"}]}},
581                "components": {"Text": {}}
582            })
583        );
584    }
585
586    #[test]
587    fn pruning_messages_drops_unreachable_definitions() {
588        let mut s2c = json!({
589            "oneOf": [{"$ref": "#/$defs/MessageA"}],
590            "$defs": {
591                "MessageA": {"type": "object", "properties": {"shared": {"$ref": "#/$defs/Shared"}}},
592                "Shared": {"type": "string"},
593                "Unused": {"type": "number"}
594            }
595        });
596        prune_messages(&mut s2c, &["MessageA".to_string()]);
597        let defs = s2c["$defs"].as_object().unwrap();
598        assert!(defs.contains_key("MessageA"));
599        assert!(
600            defs.contains_key("Shared"),
601            "a referenced type must survive"
602        );
603        assert!(!defs.contains_key("Unused"));
604    }
605
606    #[test]
607    fn a_flat_message_map_prunes_by_property_name() {
608        let mut s2c = json!({
609            "properties": {
610                "beginRendering": {"type": "object"},
611                "surfaceUpdate": {"type": "object"},
612                "deleteSurface": {"type": "object"}
613            },
614            "required": ["surfaceId"]
615        });
616        prune_messages(
617            &mut s2c,
618            &["beginRendering".to_string(), "deleteSurface".to_string()],
619        );
620        assert_eq!(
621            s2c,
622            json!({
623                "properties": {
624                    "beginRendering": {"type": "object"},
625                    "deleteSurface": {"type": "object"}
626                },
627                "required": ["surfaceId"]
628            })
629        );
630    }
631
632    #[test]
633    fn common_types_follow_what_still_references_them() {
634        let bundle = SchemaBundle {
635            s2c: Value::Null,
636            common_types: json!({"$defs": {
637                "TypeForA": {"type": "string", "$ref": "#/$defs/SubtypeForA"},
638                "TypeForB": {"type": "number"},
639                "SubtypeForA": {"type": "boolean"}
640            }}),
641            catalog: json!({"catalogId": "basic", "components": {
642                "CompA": {"$ref": "common_types.json#/$defs/TypeForA"},
643                "CompB": {"$ref": "common_types.json#/$defs/TypeForB"}
644            }}),
645            custom_cuttable_keys: None,
646        };
647        let pruned = bundle.prune(&["CompA".to_string()], &[]);
648        assert_eq!(
649            pruned.common_types,
650            json!({"$defs": {
651                "TypeForA": {"type": "string", "$ref": "#/$defs/SubtypeForA"},
652                "SubtypeForA": {"type": "boolean"}
653            }})
654        );
655    }
656
657    #[test]
658    fn strict_validation_is_removed_but_explicit_true_is_kept() {
659        let mut schema = json!({
660            "type": "object",
661            "properties": {
662                "a": {"type": "string", "additionalProperties": false},
663                "b": {"type": "array", "items": {"type": "object", "additionalProperties": false}}
664            },
665            "additionalProperties": false
666        });
667        remove_strict_validation(&mut schema);
668        assert_eq!(
669            schema,
670            json!({
671                "type": "object",
672                "properties": {
673                    "a": {"type": "string"},
674                    "b": {"type": "array", "items": {"type": "object"}}
675                }
676            })
677        );
678
679        let mut kept = json!({"type": "object", "additionalProperties": true});
680        remove_strict_validation(&mut kept);
681        assert_eq!(
682            kept,
683            json!({"type": "object", "additionalProperties": true})
684        );
685    }
686
687    #[test]
688    fn cuttable_keys_default_until_overridden() {
689        let bundle = SchemaBundle::default();
690        assert!(bundle.cuttable_keys().contains(&"text".to_string()));
691
692        let custom = SchemaBundle {
693            custom_cuttable_keys: Some(vec!["customKey1".to_string()]),
694            ..SchemaBundle::default()
695        };
696        assert_eq!(custom.cuttable_keys(), vec!["customKey1".to_string()]);
697    }
698
699    #[test]
700    fn globs_cover_stars_classes_ranges_and_negation() {
701        assert!(glob_match("*.json", "example1.json"));
702        assert!(!glob_match("*.json", "notes.txt"));
703        assert!(glob_match("user_*.json", "user_profile.json"));
704        assert!(!glob_match("user_*.json", "admin_profile.json"));
705        assert!(glob_match("step[1-2].json", "step1.json"));
706        assert!(!glob_match("step[1-2].json", "step3.json"));
707        assert!(glob_match("[!i]*.json", "visible.json"));
708        assert!(!glob_match("[!i]*.json", "index.json"));
709        assert!(glob_match("**/*.json", "top.json"));
710        assert!(glob_match("**/*.json", "nested/deep.json"));
711        // A bare star must not cross a directory boundary.
712        assert!(!glob_match("*.json", "nested/deep.json"));
713        assert!(glob_match("a?c.json", "abc.json"));
714    }
715
716    #[test]
717    fn a_missing_examples_path_is_not_an_error() {
718        assert_eq!(load_examples(None, false).unwrap(), "");
719        assert_eq!(
720            load_examples(Some(Path::new("/no/such/place")), false).unwrap(),
721            ""
722        );
723    }
724}