Skip to main content

warble_cli/
lib.rs

1//! `warble_cli` — the native host library behind the `warble` binary.
2//!
3//! Holds the pieces the binary and the golden integration tests share. The headline is
4//! [`compile_project_to_ir`]: the real front-end host path — read a Warble project's files, build
5//! the MDL [`ContextLoader`] over the bound wren project, and run the sans-IO core compiler with it
6//! injected. (The binary's `dispatch`/`render`/`manifest`/`eval` subcommands stay in `main.rs`.)
7//!
8//! [`blast_radius_for_project`] reuses the same project-resolution path to answer a `blast_radius`
9//! query without running a full compile — the host side of the `warble blast-radius` subcommand.
10//!
11//! Component *resolution* — turning a profile's `use: <id>` mount into a directory to read
12//! `component.yml`/steps from — is entirely a host concern (see [`ComponentSource`] /
13//! `resolve_component_dir`): the core compiler never touches a filesystem, it only ever sees the
14//! already-resolved `HashMap<String, ComponentFile>` this module builds.
15//!
16//! This is the crate that becomes the `warble` binary itself — end users install just this one.
17//! It links in both dispatcher back-ends (`warble-claude-code`, `warble-vercel`) and the
18//! `warble-mdl-context` binding directly; none of those three crates is a standalone tool, and
19//! `warble dispatch --target ...` simply selects which linked-in back-end handles the compiled
20//! IR.
21
22pub mod gate;
23
24use std::collections::HashMap;
25use std::fmt;
26use std::path::{Path, PathBuf};
27
28use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
29use warble_claude_code::ir::SUPPORTED_IR_VERSION;
30use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
31
32/// The precedence class a [`ComponentSource`] belongs to. Precedence is a fixed rule *between*
33/// kinds — `Local` always outranks `Hub` — not derived from the order sources happen to be listed
34/// in. There is deliberately no rule *within* a kind: if the same component id is found in two
35/// sources of the same kind, `resolve_component_dir` treats that as ambiguous rather than
36/// guessing (e.g. "first in the list wins"), because nothing declares which of two equally-ranked
37/// sources should win.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SourceKind {
40    /// A host- or product-specific component directory — e.g. a profile's own `components/`
41    /// override, or an external component library a consumer mounts alongside the Hub. Wins over
42    /// `Hub` whenever both define the same id.
43    Local,
44    /// The shared, generic Warble component library (this checkout's `hub/components`, or an
45    /// externally-supplied equivalent). The fallback tier: consulted only for ids no `Local`
46    /// source defines.
47    Hub,
48}
49
50impl fmt::Display for SourceKind {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(match self {
53            SourceKind::Local => "local",
54            SourceKind::Hub => "hub",
55        })
56    }
57}
58
59/// One explicitly-configured place a mounted component's files may live: `components_dir` is a
60/// directory whose immediate children are `<id>/component.yml` (+ its `steps/*`). The host builds
61/// the full ordered/kinded list up front (see [`compile_project_to_ir_with_sources`]) — resolution
62/// never discovers sources on its own (no ancestor/filesystem-root walk).
63#[derive(Debug, Clone)]
64pub struct ComponentSource {
65    pub kind: SourceKind,
66    pub components_dir: PathBuf,
67}
68
69impl ComponentSource {
70    pub fn local(components_dir: impl Into<PathBuf>) -> Self {
71        ComponentSource {
72            kind: SourceKind::Local,
73            components_dir: components_dir.into(),
74        }
75    }
76
77    pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
78        ComponentSource {
79            kind: SourceKind::Hub,
80            components_dir: components_dir.into(),
81        }
82    }
83
84    fn candidate(&self, id: &str) -> PathBuf {
85        self.components_dir.join(id)
86    }
87}
88
89/// This checkout's own Hub component library — a fixed sibling of the `cli` crate's manifest dir,
90/// known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking the filesystem at
91/// runtime. This is what backs [`compile_project_to_ir`]'s default source list, so every in-repo
92/// example/eval profile keeps resolving its Hub-mounted components exactly as before.
93fn in_repo_hub_dir() -> PathBuf {
94    Path::new(env!("CARGO_MANIFEST_DIR"))
95        .join("..")
96        .join("hub")
97        .join("components")
98}
99
100/// The default two-source list used by [`compile_project_to_ir`]: the project's own `components/`
101/// (`Local`, highest precedence — lets a profile deliberately diverge from the Hub, e.g. an
102/// eval/demo substrate with intentionally different anatomy) plus this checkout's Hub (`Hub`,
103/// fallback). A host mounting an *additional* local component library (e.g. a product-specific
104/// one, alongside the Hub) extends this list — see [`compile_project_to_ir_with_sources`].
105pub fn default_component_sources(project_dir: &Path) -> Vec<ComponentSource> {
106    vec![
107        ComponentSource::local(project_dir.join("components")),
108        ComponentSource::hub(in_repo_hub_dir()),
109    ]
110}
111
112/// Resolve where a mounted component's directory lives, against an explicit, ordered set of
113/// sources — never by walking the filesystem. Within the highest-precedence [`SourceKind`] that
114/// contains a match, exactly one source must define `id`; more than one is **ambiguous** (loud
115/// fail — no rule picks between equally-ranked sources) and zero across every source is
116/// **unresolved** (loud fail, listing everywhere it looked).
117fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
118    for kind in [SourceKind::Local, SourceKind::Hub] {
119        let matches: Vec<&ComponentSource> = sources
120            .iter()
121            .filter(|source| source.kind == kind)
122            .filter(|source| source.candidate(id).join("component.yml").is_file())
123            .collect();
124
125        match matches.as_slice() {
126            [] => continue,
127            [only] => return Ok(only.candidate(id)),
128            many => {
129                let dirs: Vec<String> = many
130                    .iter()
131                    .map(|s| s.candidate(id).display().to_string())
132                    .collect();
133                return Err(format!(
134                    "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
135                     precedence rule distinguishes sources of the same kind",
136                    many.len(),
137                    dirs.join(", ")
138                ));
139            }
140        }
141    }
142
143    let searched: Vec<String> = sources
144        .iter()
145        .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
146        .collect();
147    Err(format!(
148        "component '{id}' not found in any configured source: {}",
149        searched.join("; ")
150    ))
151}
152
153/// Turns a parsed `context/binding.yml` into the [`ContextLoader`] the compiler probes.
154///
155/// The counterpart of [`ComponentSource`] for context: components could already be supplied by a
156/// host, context could not. A host implements this to bind a semantic layer this checkout cannot
157/// read itself — one held by a service, for instance — without warble learning anything about it.
158///
159/// **A resolver is free to do no I/O at all, and `warble compile` must stay runnable offline and
160/// without credentials.** A host binding a remote layer is expected to resolve from a snapshot it
161/// pulled earlier, or to return a loader that declines the schema probes
162/// (`ContextLoader::can_answer`) rather than reaching for the network mid-compile.
163pub trait ContextResolver {
164    /// Build the loader for `binding`. `project_dir` is the Warble project directory, so a resolver
165    /// reading from disk can resolve a relative `binding.project` against it.
166    fn resolve(
167        &self,
168        binding: &BindingFile,
169        project_dir: &Path,
170    ) -> Result<Box<dyn ContextLoader>, String>;
171}
172
173/// The context kinds this checkout can read itself: `wren_project` and `raw_source`. A host that
174/// needs another kind wraps this — delegating the two it knows and handling its own.
175pub struct BuiltinContextResolver;
176
177impl ContextResolver for BuiltinContextResolver {
178    fn resolve(
179        &self,
180        binding: &BindingFile,
181        project_dir: &Path,
182    ) -> Result<Box<dyn ContextLoader>, String> {
183        // `external` names a layer that is not on this machine, so it must be resolved before any
184        // path is built — joining a locator like `remote-service://analytics` onto a directory would produce
185        // nonsense, and reading anything at all would break the offline guarantee.
186        if binding.kind == BindingFile::EXTERNAL {
187            return Ok(Box::new(warble::ExternalContext::new()));
188        }
189        // The remaining built-in kinds read a directory, so `project` is a path for them. A host
190        // kind's `project` may be anything at all, which is why this resolution lives per-kind
191        // rather than in the caller.
192        let path = project_dir.join(&binding.project);
193        match binding.kind.as_str() {
194            BindingFile::WREN_PROJECT => {
195                if let Some(sources) = read_project_dir(&path)
196                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
197                {
198                    // Use the error-preserving `try_from_sources` (not `from_sources`) so a real
199                    // assembly failure's text survives into the `mdl_parseable` precondition message
200                    // instead of being silently dropped in favor of only the generic floor message.
201                    return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
202                        Ok(ctx) => ctx,
203                        Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
204                    }));
205                }
206                // Before kinds were declared, this directory would have been silently accepted as a
207                // raw source. Guessing across kinds is exactly what declaring one is meant to stop,
208                // so say what to write instead.
209                if path.join("schema.json").is_file() {
210                    return Err(format!(
211                        "{} holds a raw source (schema.json), not a wren project — declare \
212                         `kind: {}` in the binding to bind it",
213                        path.display(),
214                        BindingFile::RAW_SOURCE
215                    ));
216                }
217                // No wren project and no raw source: an unparseable context, so the failure surfaces
218                // as the `mdl_parseable` precondition rather than as an I/O error here.
219                Ok(Box::new(MdlContext::unparseable()))
220            }
221            BindingFile::RAW_SOURCE => {
222                let raw = read_raw_dir(&path)
223                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
224                    .ok_or_else(|| {
225                        format!(
226                            "binding declares `kind: {}` but {} has no schema.json",
227                            BindingFile::RAW_SOURCE,
228                            path.display()
229                        )
230                    })?;
231                Ok(Box::new(RawSourceContext::from_sources(&raw)))
232            }
233            other => Err(format!(
234                "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A host \
235                 that defines '{other}' must supply a ContextResolver for it — see \
236                 `compile_project_to_ir_with`.",
237                BindingFile::WREN_PROJECT,
238                BindingFile::RAW_SOURCE,
239                BindingFile::EXTERNAL
240            )),
241        }
242    }
243}
244
245/// Compile a Warble project directory into its IR JSON, using the real MDL `ContextLoader` over the
246/// bound wren project and the default component source list (project-local `components/` + this
247/// checkout's Hub — see [`default_component_sources`]). This is what every in-repo example/eval
248/// profile and integration test compiles through.
249pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
250    compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir))
251}
252
253/// Compile a Warble project directory into its IR JSON, resolving mounted components against an
254/// explicit, caller-supplied source list instead of the in-repo default (see [`ComponentSource`]).
255/// This is the seam a host outside this checkout uses to mount its own local component library
256/// alongside (or instead of) the Hub — e.g. `[ComponentSource::local(my_components),
257/// ComponentSource::hub(path_to_warble_hub)]`. All filesystem reads happen here; the core compiler
258/// stays sans-IO — it only ever receives the resolved `HashMap<String, ComponentFile>` built below.
259pub fn compile_project_to_ir_with_sources(
260    project_dir: &Path,
261    sources: &[ComponentSource],
262) -> Result<serde_json::Value, String> {
263    compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
264}
265
266/// As [`compile_project_to_ir_with_sources`], resolving the context binding through a
267/// caller-supplied [`ContextResolver`] instead of [`BuiltinContextResolver`]. This is the seam a
268/// host uses to bind a context kind this checkout cannot read — the context-side counterpart of
269/// passing your own [`ComponentSource`] list.
270pub fn compile_project_to_ir_with(
271    project_dir: &Path,
272    sources: &[ComponentSource],
273    resolver: &dyn ContextResolver,
274) -> Result<serde_json::Value, String> {
275    let profile_path = project_dir.join("profile.yml");
276    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
277        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
278
279    let binding_path = project_dir.join(&profile.context.project);
280    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
281        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
282
283    let context = resolver.resolve(&binding, project_dir)?;
284
285    let mut components: HashMap<String, ComponentFile> = HashMap::new();
286    let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
287
288    for mount in &profile.components {
289        let component_dir = resolve_component_dir(sources, &mount.use_id)?;
290        let component_path = component_dir.join("component.yml");
291        let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
292            .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
293
294        let mut steps: HashMap<String, String> = HashMap::new();
295        for step in &component.llm_steps {
296            let step_path = component_dir.join(&step.prompt_ref);
297            steps.insert(step.name.clone(), read_file(&step_path)?);
298        }
299        step_contents.insert(component.id.clone(), steps);
300        components.insert(component.id.clone(), component);
301    }
302
303    warble::compile(
304        &profile,
305        &components,
306        &binding.project,
307        context.as_ref(),
308        &step_contents,
309    )
310    .map_err(|e| e.to_string())
311}
312
313fn read_file(path: &Path) -> Result<String, String> {
314    std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
315}
316
317/// Compute the [`warble::BlastRadius`] of `node` in a Warble project's bound wren project. Resolves
318/// the project the same way [`compile_project_to_ir`] does (profile.yml → context binding →
319/// wren project → `MdlContext`), but stops short of a full compile — just the lineage query.
320pub fn blast_radius_for_project(
321    project_dir: &Path,
322    node: &str,
323) -> Result<warble::BlastRadius, String> {
324    blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
325}
326
327/// As [`blast_radius_for_project`], resolving the context binding through a caller-supplied
328/// [`ContextResolver`]. Kept in step with [`compile_project_to_ir_with`] so a host kind is bindable
329/// on both paths — a lineage query over a context only the host can load is no less valid than a
330/// compile over it.
331pub fn blast_radius_for_project_with(
332    project_dir: &Path,
333    node: &str,
334    resolver: &dyn ContextResolver,
335) -> Result<warble::BlastRadius, String> {
336    let profile_path = project_dir.join("profile.yml");
337    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
338        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
339
340    let binding_path = project_dir.join(&profile.context.project);
341    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
342        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
343
344    let context = resolver.resolve(&binding, project_dir)?;
345
346    if !context.is_parseable() {
347        return Err(format!(
348            "bound context '{}' is not parseable — cannot compute blast radius",
349            binding.project
350        ));
351    }
352
353    Ok(context.lineage().blast_radius(node))
354}
355
356#[cfg(test)]
357mod component_source_tests {
358    use super::{resolve_component_dir, ComponentSource};
359    use std::fs;
360    use std::path::Path;
361
362    /// Create `<dir>/<id>/component.yml` (contents are irrelevant — resolution only checks the
363    /// file exists) so a source directory "defines" `id`.
364    fn stub_component(dir: &Path, id: &str) {
365        let component_dir = dir.join(id);
366        fs::create_dir_all(&component_dir).unwrap();
367        fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
368    }
369
370    #[test]
371    fn resolves_from_the_only_source_that_defines_it() {
372        let hub = tempfile::tempdir().unwrap();
373        stub_component(hub.path(), "answer_query");
374
375        let sources = vec![ComponentSource::hub(hub.path())];
376        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
377        assert_eq!(resolved, hub.path().join("answer_query"));
378    }
379
380    #[test]
381    fn local_overrides_hub_when_both_define_the_same_id() {
382        let local = tempfile::tempdir().unwrap();
383        let hub = tempfile::tempdir().unwrap();
384        stub_component(local.path(), "answer_query");
385        stub_component(hub.path(), "answer_query");
386
387        let sources = vec![
388            ComponentSource::local(local.path()),
389            ComponentSource::hub(hub.path()),
390        ];
391        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
392        assert_eq!(
393            resolved,
394            local.path().join("answer_query"),
395            "a Local source must win over a Hub source defining the same id"
396        );
397    }
398
399    #[test]
400    fn falls_back_to_hub_when_local_does_not_define_the_id() {
401        let local = tempfile::tempdir().unwrap();
402        let hub = tempfile::tempdir().unwrap();
403        stub_component(hub.path(), "answer_query");
404
405        let sources = vec![
406            ComponentSource::local(local.path()),
407            ComponentSource::hub(hub.path()),
408        ];
409        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
410        assert_eq!(resolved, hub.path().join("answer_query"));
411    }
412
413    #[test]
414    fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
415        let local = tempfile::tempdir().unwrap();
416        let hub = tempfile::tempdir().unwrap();
417
418        let sources = vec![
419            ComponentSource::local(local.path()),
420            ComponentSource::hub(hub.path()),
421        ];
422        let err = resolve_component_dir(&sources, "missing").unwrap_err();
423        assert!(err.contains("missing"));
424        assert!(err.contains(&local.path().display().to_string()));
425        assert!(err.contains(&hub.path().display().to_string()));
426    }
427
428    #[test]
429    fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
430        let local_a = tempfile::tempdir().unwrap();
431        let local_b = tempfile::tempdir().unwrap();
432        stub_component(local_a.path(), "answer_query");
433        stub_component(local_b.path(), "answer_query");
434
435        let sources = vec![
436            ComponentSource::local(local_a.path()),
437            ComponentSource::local(local_b.path()),
438        ];
439        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
440        assert!(err.contains("ambiguous"));
441        assert!(err.contains(&local_a.path().display().to_string()));
442        assert!(err.contains(&local_b.path().display().to_string()));
443    }
444
445    #[test]
446    fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
447        // Even though `hub` alone would resolve `answer_query` unambiguously, an ambiguous match
448        // at the higher-precedence `Local` kind must fail loud rather than silently falling back.
449        let local_a = tempfile::tempdir().unwrap();
450        let local_b = tempfile::tempdir().unwrap();
451        let hub = tempfile::tempdir().unwrap();
452        stub_component(local_a.path(), "answer_query");
453        stub_component(local_b.path(), "answer_query");
454        stub_component(hub.path(), "answer_query");
455
456        let sources = vec![
457            ComponentSource::local(local_a.path()),
458            ComponentSource::local(local_b.path()),
459            ComponentSource::hub(hub.path()),
460        ];
461        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
462        assert!(err.contains("ambiguous"));
463    }
464}
465
466/// `warble_eval_runner::ComplianceIr` (the type `eval compliance` deserializes into) is
467/// *deliberately* narrower than `WarbleIr` — its own doc comment says so, so a compiled-in
468/// `warble_ir_version` field never belongs on that type. But `eval compliance` is not fed an
469/// arbitrary subset: every real caller hands it the same complete `ir.json` `dispatch`/`manifest`
470/// consume (confirmed by `eval/golden/compliance/ground_truth.yaml`'s own comment — its two golden
471/// IRs, `examples/mutate-agent/ir.golden.json` and `examples/analysis-agent/ir.golden.json`, are "reused
472/// as-is, not new fixtures"). So the version gate belongs here, at the CLI boundary, checked on the
473/// raw JSON before `ComplianceIr` ever sees it — against the same
474/// `warble_claude_code::ir::SUPPORTED_IR_VERSION` that the binary's own `load_ir` already gates
475/// `dispatch`/`manifest` against, so every CLI-level IR consumer rejects an out-of-range version
476/// the same way.
477pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
478    let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
479        format!(
480            "failed to parse IR {} for version check: {e}",
481            path.display()
482        )
483    })?;
484    // Absent and present-but-not-a-string are reported separately on purpose: collapsing them sends
485    // someone whose IR *does* carry the field looking for a missing key that is right there.
486    match parsed.get("warble_ir_version") {
487        None => Err(format!(
488            "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
489             IR, not a hand-written subset",
490            path.display()
491        )),
492        Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
493        Some(serde_json::Value::String(v)) => Err(format!(
494            "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
495            path.display()
496        )),
497        Some(other) => Err(format!(
498            "warble_ir_version in {} is {}, not a string — eval compliance understands: \
499             {SUPPORTED_IR_VERSION}",
500            path.display(),
501            match other {
502                serde_json::Value::Null => "null",
503                serde_json::Value::Bool(_) => "a boolean",
504                serde_json::Value::Number(_) => "a number",
505                serde_json::Value::Array(_) => "an array",
506                _ => "an object",
507            }
508        )),
509    }
510}
511
512#[cfg(test)]
513mod compliance_ir_version_tests {
514    use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
515    use std::path::Path;
516
517    fn check(raw: &str) -> Result<(), String> {
518        check_compliance_ir_version(raw, Path::new("ir.json"))
519    }
520
521    #[test]
522    fn the_supported_version_passes() {
523        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
524        assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
525    }
526
527    #[test]
528    fn a_missing_field_is_reported_as_missing() {
529        let err = check(r#"{"components": []}"#).expect_err("must reject");
530        assert!(err.contains("has no warble_ir_version"), "got: {err}");
531    }
532
533    #[test]
534    fn an_out_of_range_version_is_reported_as_unsupported() {
535        let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
536        assert!(
537            err.contains("unsupported warble_ir_version '0.2'"),
538            "got: {err}"
539        );
540        assert!(
541            err.contains(SUPPORTED_IR_VERSION),
542            "must name what it does accept: {err}"
543        );
544    }
545
546    #[test]
547    fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
548        // Guards against a `starts_with` regression: "0.30" is not "0.3".
549        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
550        let err = check(&raw).expect_err("a superstring is a different version");
551        assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
552    }
553
554    /// The matrix this module exists for: every non-string JSON type must be reported as a *type*
555    /// error naming what it actually is — never as "the field is missing", which would send someone
556    /// looking for a key that is sitting right there. `null` is the one that regressed before.
557    #[test]
558    fn every_non_string_type_is_reported_as_a_type_error() {
559        for (literal, expected) in [
560            ("null", "is null"),
561            ("true", "is a boolean"),
562            ("3", "is a number"),
563            ("[]", "is an array"),
564            ("{}", "is an object"),
565        ] {
566            let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
567            let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
568            assert!(
569                err.contains(expected),
570                "for {literal} expected '{expected}', got: {err}"
571            );
572            assert!(
573                !err.contains("has no warble_ir_version"),
574                "for {literal} the field is present, not missing: {err}"
575            );
576        }
577    }
578
579    #[test]
580    fn unparseable_json_is_reported_as_a_parse_failure() {
581        let err = check("not json at all").expect_err("must reject");
582        assert!(err.contains("failed to parse IR"), "got: {err}");
583    }
584}