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