Skip to main content

leviath_tools/validate/
format.rs

1//! Built-in well-formedness checks for common output formats.
2//!
3//! These answer one question: **is the submission actually the format it claims
4//! to be?** They do not answer "does it have the right shape", which is JSON
5//! Schema's job (for JSON) or an agent's own Rhai validator's job (for anything
6//! else).
7//!
8//! The distinction is the whole reason this module is small. Checking that a
9//! document parses is one call into a parser. Checking that it matches an XSD,
10//! a JSON Schema, or a GraphQL schema means owning that format's schema
11//! language, which is exactly the per-format weight the output system is built
12//! to avoid.
13//!
14//! What this catches is the failure that actually happens: the model wraps its
15//! answer in ``` fences, adds a sentence of preamble, or hands back JSON when
16//! the stage asked for XML. All three are a parse error, and all three are worth
17//! bouncing back for a retry.
18//!
19//! A format with no entry here validates nothing, which is the honest outcome:
20//! the label is opaque by design, and most labels are not formats this crate has
21//! ever heard of.
22
23/// Whether a built-in check exists for `format`.
24///
25/// Matched case-insensitively on the whole label, so `"JSON"` and `"json"` are
26/// the same check and `"json-lines"` is neither. A near-miss deliberately gets
27/// no validation rather than the wrong one.
28pub fn has_builtin(format: &str) -> bool {
29    checker_for(format).is_some()
30}
31
32/// Every format this crate can check, for documentation and diagnostics.
33pub const BUILTIN_FORMATS: &[&str] = &["json", "xml", "yaml", "csv", "toml"];
34
35/// A well-formedness check: `Ok` when the content parses, `Err` with a reason
36/// the agent can act on when it does not.
37type Checker = fn(&str) -> Result<(), String>;
38
39/// The checker for `format`, if there is one.
40fn checker_for(format: &str) -> Option<Checker> {
41    // Trimmed and lowercased, because a label is written by a person and
42    // `"JSON "` means JSON. Nothing cleverer: a label this does not recognize is
43    // simply not validated.
44    match format.trim().to_ascii_lowercase().as_str() {
45        "json" => Some(check_json),
46        "xml" => Some(check_xml),
47        "yaml" | "yml" => Some(check_yaml),
48        "csv" => Some(check_csv),
49        "toml" => Some(check_toml),
50        _ => None,
51    }
52}
53
54/// Check `content` against the built-in for `format`.
55///
56/// `Ok(())` when it parses, when the format has no built-in, or when `format` is
57/// absent. `Err` carries a message written for the agent to act on.
58pub fn check(format: Option<&str>, content: &str) -> Result<(), String> {
59    let Some(checker) = format.and_then(checker_for) else {
60        return Ok(());
61    };
62    checker(content)
63}
64
65fn check_json(content: &str) -> Result<(), String> {
66    serde_json::from_str::<serde_json::Value>(content)
67        .map(|_| ())
68        .map_err(|e| e.to_string())
69}
70
71fn check_toml(content: &str) -> Result<(), String> {
72    content
73        .parse::<toml::Table>()
74        .map(|_| ())
75        .map_err(|e| e.to_string())
76}
77
78/// Parse the document, unless it uses anchors or aliases.
79///
80/// YAML alias expansion is exponential, and this parser offers no bound on it.
81/// 268 bytes of nested aliases takes seconds; one more level takes half a
82/// minute. That matters here more than it would elsewhere: this check runs
83/// inline on the daemon's tick loop, over content an agent produced - and an
84/// agent can be talked into producing anything by a page it fetched. A single
85/// crafted answer would stall every agent in the shared world, not just its own
86/// run.
87///
88/// So a document using aliases is **not checked**, rather than rejected. The
89/// same stance an uncompilable JSON Schema gets: being unable to check
90/// something is not evidence it is wrong, and refusing the answer would cost an
91/// agent its work over a construct it is allowed to use.
92///
93/// Detection is deliberately over-eager. Mistaking `3 * 4` for an alias costs a
94/// skipped check; missing a real one costs the daemon.
95fn check_yaml(content: &str) -> Result<(), String> {
96    if uses_anchors_or_aliases(content) {
97        return Ok(());
98    }
99    yaml_rust2::YamlLoader::load_from_str(content)
100        .map(|_| ())
101        .map_err(|e| e.to_string())
102}
103
104/// Whether `content` appears to use a YAML anchor (`&name`) or alias (`*name`).
105///
106/// A sigil in a value position: at the start of a line or after a space or a
107/// flow-collection punctuation mark, and followed by something that could be a
108/// name. Scans once, so it cannot itself be the expensive step.
109fn uses_anchors_or_aliases(content: &str) -> bool {
110    let bytes = content.as_bytes();
111    bytes.iter().enumerate().any(|(i, &b)| {
112        if b != b'&' && b != b'*' {
113            return false;
114        }
115        let before_ok = match i {
116            0 => true,
117            _ => matches!(
118                bytes[i - 1],
119                b' ' | b'\t' | b'\n' | b'\r' | b'[' | b'{' | b',' | b'-'
120            ),
121        };
122        let after_ok = bytes
123            .get(i + 1)
124            .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_');
125        before_ok && after_ok
126    })
127}
128
129/// Read every XML event to the end, tracking element depth.
130///
131/// The reader reports a *mismatched* closing tag on its own, but not one that
132/// never arrives: `<a>` with no `</a>` reaches EOF without complaint. So depth
133/// is counted here, and anything still open at the end is the error.
134///
135/// A document with no elements at all is refused too. Prose parses as a single
136/// text event and would otherwise pass as "valid XML", which is the wrong answer
137/// for a stage that asked for XML and got a paragraph.
138fn check_xml(content: &str) -> Result<(), String> {
139    use quick_xml::events::Event;
140    let mut reader = quick_xml::Reader::from_str(content);
141    let mut buf = Vec::new();
142    let mut depth: i64 = 0;
143    let mut saw_element = false;
144    loop {
145        match reader.read_event_into(&mut buf) {
146            Ok(Event::Eof) => break,
147            Ok(Event::Start(_)) => {
148                saw_element = true;
149                depth += 1;
150            }
151            Ok(Event::Empty(_)) => saw_element = true,
152            Ok(Event::End(_)) => depth -= 1,
153            Ok(_) => {}
154            Err(e) => return Err(e.to_string()),
155        }
156        buf.clear();
157    }
158    if !saw_element {
159        return Err("no XML elements found; this looks like plain text".to_string());
160    }
161    match depth {
162        0 => Ok(()),
163        open => Err(format!(
164            "{open} element(s) left unclosed at the end of the document"
165        )),
166    }
167}
168
169/// Read every record, which surfaces an unbalanced quote or a ragged row.
170///
171/// Ragged rows are the point: a CSV whose columns drift is the failure a
172/// consumer actually hits, and the reader reports it as an error rather than
173/// quietly yielding short records.
174fn check_csv(content: &str) -> Result<(), String> {
175    if content.trim().is_empty() {
176        return Err("the CSV is empty".to_string());
177    }
178    let mut reader = csv::ReaderBuilder::new()
179        .flexible(false)
180        .from_reader(content.as_bytes());
181    for record in reader.records() {
182        record.map_err(|e| e.to_string())?;
183    }
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests;