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