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 sha2::{Digest, Sha256};
30use warble::{BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile};
31use warble_claude_code::ir::SUPPORTED_IR_VERSION;
32use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
33
34/// The precedence class a [`ComponentSource`] belongs to. Precedence is a fixed rule *between*
35/// kinds — `Local` always outranks `Hub` — not derived from the order sources happen to be listed
36/// in. There is deliberately no rule *within* a kind: if the same component id is found in two
37/// sources of the same kind, `resolve_component_dir` treats that as ambiguous rather than
38/// guessing (e.g. "first in the list wins"), because nothing declares which of two equally-ranked
39/// sources should win.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SourceKind {
42    /// A host- or product-specific component directory — e.g. a profile's own `components/`
43    /// override, or an external component library a consumer mounts alongside the Hub. Wins over
44    /// `Hub` whenever both define the same id.
45    Local,
46    /// The shared, generic Warble component library (this checkout's `hub/components`, or an
47    /// externally-supplied equivalent). The fallback tier: consulted only for ids no `Local`
48    /// source defines.
49    Hub,
50}
51
52impl fmt::Display for SourceKind {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(match self {
55            SourceKind::Local => "local",
56            SourceKind::Hub => "hub",
57        })
58    }
59}
60
61/// One explicitly-configured place a mounted component's files may live: `components_dir` is a
62/// directory whose immediate children are `<id>/component.yml` (+ its `steps/*`). The host builds
63/// the full ordered/kinded list up front (see [`compile_project_to_ir_with_sources`]) — resolution
64/// never discovers sources on its own (no ancestor/filesystem-root walk).
65#[derive(Debug, Clone)]
66pub struct ComponentSource {
67    pub kind: SourceKind,
68    pub components_dir: PathBuf,
69}
70
71impl ComponentSource {
72    pub fn local(components_dir: impl Into<PathBuf>) -> Self {
73        ComponentSource {
74            kind: SourceKind::Local,
75            components_dir: components_dir.into(),
76        }
77    }
78
79    pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
80        ComponentSource {
81            kind: SourceKind::Hub,
82            components_dir: components_dir.into(),
83        }
84    }
85
86    fn candidate(&self, id: &str) -> PathBuf {
87        self.components_dir.join(id)
88    }
89}
90
91/// This checkout's own Hub component library — a fixed sibling of the `cli` crate's manifest dir,
92/// known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking the filesystem at
93/// runtime. This is what backs [`compile_project_to_ir`]'s default source list, so every in-repo
94/// example/eval profile keeps resolving its Hub-mounted components exactly as before.
95fn in_repo_hub_dir() -> PathBuf {
96    Path::new(env!("CARGO_MANIFEST_DIR"))
97        .join("..")
98        .join("hub")
99        .join("components")
100}
101
102/// The Hub source used when no `--hub-dir` override is given: this checkout's own
103/// `hub/components/` if it exists on disk, otherwise the per-user cache of the Hub release
104/// matching `hub_version` (or this binary's own version, when `hub_version` is `None`), fetching
105/// it over the network first if the cache doesn't already hold a verified copy.
106///
107/// The in-repo check comes first and is unconditional: an in-repo Hub directory always wins and
108/// never triggers a fetch, even when `hub_version` names a different version than this binary's
109/// 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
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 resolves without help: `wren_project` and `raw_source` (read
212/// natively), `external` (read nothing) and `prepared` (read a projection the host already
213/// resolved). A host that needs another kind wraps this — delegating the ones it knows and
214/// handling its own.
215///
216/// `prepared` is the kind that does not require warble to speak the semantic format at all, so a
217/// host whose format has no adapter here binds through it rather than through a linked resolver —
218/// which is the only option open to a host that drives `warble` as a subprocess.
219pub struct BuiltinContextResolver;
220
221impl ContextResolver for BuiltinContextResolver {
222    fn resolve(
223        &self,
224        binding: &BindingFile,
225        project_dir: &Path,
226    ) -> Result<Box<dyn ContextLoader>, String> {
227        // `external` names a layer that is not on this machine, so it must be resolved before any
228        // path is built — joining a locator like `remote-service://analytics` onto a directory would produce
229        // nonsense, and reading anything at all would break the offline guarantee.
230        if binding.kind == BindingFile::EXTERNAL {
231            return Ok(Box::new(warble::ExternalContext::new()));
232        }
233        // The remaining built-in kinds read a directory, so `project` is a path for them. A host
234        // kind's `project` may be anything at all, which is why this resolution lives per-kind
235        // rather than in the caller.
236        let path = project_dir.join(&binding.project);
237        match binding.kind.as_str() {
238            BindingFile::WREN_PROJECT => {
239                if let Some(sources) = read_project_dir(&path)
240                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
241                {
242                    // Use the error-preserving `try_from_sources` (not `from_sources`) so a real
243                    // assembly failure's text survives into the `mdl_parseable` precondition message
244                    // instead of being silently dropped in favor of only the generic floor message.
245                    return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
246                        Ok(ctx) => ctx,
247                        Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
248                    }));
249                }
250                // Before kinds were declared, this directory would have been silently accepted as a
251                // raw source. Guessing across kinds is exactly what declaring one is meant to stop,
252                // so say what to write instead.
253                if path.join("schema.json").is_file() {
254                    return Err(format!(
255                        "{} holds a raw source (schema.json), not a wren project — declare \
256                         `kind: {}` in the binding to bind it",
257                        path.display(),
258                        BindingFile::RAW_SOURCE
259                    ));
260                }
261                // No wren project and no raw source: an unparseable context, so the failure surfaces
262                // as the `mdl_parseable` precondition rather than as an I/O error here.
263                Ok(Box::new(MdlContext::unparseable()))
264            }
265            BindingFile::RAW_SOURCE => {
266                let raw = read_raw_dir(&path)
267                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
268                    .ok_or_else(|| {
269                        format!(
270                            "binding declares `kind: {}` but {} has no schema.json",
271                            BindingFile::RAW_SOURCE,
272                            path.display()
273                        )
274                    })?;
275                Ok(Box::new(RawSourceContext::from_sources(&raw)))
276            }
277            BindingFile::PREPARED => {
278                // `project` stays what it is for every other kind: the bound layer's identity,
279                // echoed into the IR and the `{{project}}` placeholder. The document is a separate
280                // field, because pointing `project` at the file would put the file's name into
281                // every prompt — telling the agent it works on a project called "context.json".
282                let document_ref = binding
283                    .extra
284                    .get("document")
285                    .and_then(|v| v.as_str())
286                    .ok_or_else(|| {
287                        format!(
288                            "binding declares `kind: {}` but no `document:` — add the path to the \
289                             prepared-context document the host wrote, and keep `project:` as the \
290                             bound layer's name",
291                            BindingFile::PREPARED
292                        )
293                    })?;
294                let document_path = project_dir.join(document_ref);
295                // A missing or malformed document is an error rather than an unparseable context:
296                // the binding named a file the host was supposed to write, so its absence is a
297                // broken pipeline, not a project without a semantic layer.
298                let document = std::fs::read_to_string(&document_path)
299                    .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
300                let context = PreparedContext::from_json(&document)
301                    .map_err(|e| format!("{}: {e}", document_path.display()))?;
302                Ok(Box::new(context))
303            }
304            other => Err(format!(
305                "unknown context kind '{other}' (this build resolves '{}', '{}', '{}' and '{}'). A \
306                 host that defines '{other}' must supply a ContextResolver for it — see \
307                 `compile_project_to_ir_with`.",
308                BindingFile::WREN_PROJECT,
309                BindingFile::RAW_SOURCE,
310                BindingFile::EXTERNAL,
311                BindingFile::PREPARED
312            )),
313        }
314    }
315}
316
317/// Compile a Warble project directory into its IR JSON, using the real MDL `ContextLoader` over the
318/// bound wren project and the default component source list (project-local `components/` + this
319/// checkout's Hub — see [`default_component_sources`]). This is what every in-repo example/eval
320/// profile and integration test compiles through.
321pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
322    compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
323}
324
325/// Compile a Warble project directory into its IR JSON, resolving mounted components against an
326/// explicit, caller-supplied source list instead of the in-repo default (see [`ComponentSource`]).
327/// This is the seam a host outside this checkout uses to mount its own local component library
328/// alongside (or instead of) the Hub — e.g. `[ComponentSource::local(my_components),
329/// ComponentSource::hub(path_to_warble_hub)]`. All filesystem reads happen here; the core compiler
330/// stays sans-IO — it only ever receives the resolved `HashMap<String, ComponentFile>` built below.
331pub fn compile_project_to_ir_with_sources(
332    project_dir: &Path,
333    sources: &[ComponentSource],
334) -> Result<serde_json::Value, String> {
335    compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
336}
337
338/// As [`compile_project_to_ir_with_sources`], resolving the context binding through a
339/// caller-supplied [`ContextResolver`] instead of [`BuiltinContextResolver`]. This is the seam a
340/// host uses to bind a context kind this checkout cannot read — the context-side counterpart of
341/// passing your own [`ComponentSource`] list.
342pub fn compile_project_to_ir_with(
343    project_dir: &Path,
344    sources: &[ComponentSource],
345    resolver: &dyn ContextResolver,
346) -> Result<serde_json::Value, String> {
347    let profile_path = project_dir.join("profile.yml");
348    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
349        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
350
351    let binding_path = project_dir.join(&profile.context.project);
352    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
353        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
354
355    let context = resolver.resolve(&binding, project_dir)?;
356
357    let mut components: HashMap<String, ComponentFile> = HashMap::new();
358    let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
359    // A profile-level slot's variants resolve against the profile's own directory, which is the
360    // project dir — the same shared rule component-level references follow, with the base dir
361    // being whatever owns the declaration.
362    let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
363    for slot in &profile.slots {
364        let mut variants: HashMap<String, String> = HashMap::new();
365        for (key, reference) in &slot.variants {
366            let label = format!("profile slot '{}' variant '{}'", slot.name, key);
367            let variant_path = resolve_file_ref(project_dir, reference, &label)?;
368            variants.insert(key.clone(), read_file(&variant_path)?);
369        }
370        profile_slots.insert(slot.name.clone(), variants);
371    }
372    let mut slot_contents = warble::SlotContents {
373        profile: profile_slots,
374        ..Default::default()
375    };
376
377    for mount in &profile.components {
378        let component_dir = resolve_component_dir(sources, &mount.use_id)?;
379        let component_path = component_dir.join("component.yml");
380        let mut component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
381            .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
382
383        let mut steps: HashMap<String, String> = HashMap::new();
384        for step in &component.llm_steps {
385            let step_path = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
386            steps.insert(step.name.clone(), read_file(&step_path)?);
387        }
388        step_contents.insert(component.id.clone(), steps);
389
390        // Slot variants are prompt text, so they are read here and travel into the IR — the same
391        // treatment `prompt_ref` gets, through the same resolution rule, because core never opens
392        // a file itself.
393        let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
394        for slot in &component.slots {
395            let mut variants: HashMap<String, String> = HashMap::new();
396            for (key, reference) in &slot.variants {
397                let label = format!("slot '{}' variant '{}'", slot.name, key);
398                let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
399                variants.insert(key.clone(), read_file(&variant_path)?);
400            }
401            slots.insert(slot.name.clone(), variants);
402        }
403        if !slots.is_empty() {
404            slot_contents.components.insert(component.id.clone(), slots);
405        }
406
407        // Assets are never read into the IR — only their identity (hash + size) is, and core never
408        // opens a file itself, so both are computed here before the declaration reaches `compile`.
409        for asset in &mut component.assets {
410            let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
411            let data = read_file_bytes(&asset_path)?;
412            asset.bytes = Some(data.len() as u64);
413            asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
414        }
415
416        components.insert(component.id.clone(), component);
417    }
418
419    warble::compile(
420        &profile,
421        &components,
422        &binding.project,
423        context.as_ref(),
424        &step_contents,
425        &slot_contents,
426    )
427    .map_err(|e| e.to_string())
428}
429
430fn read_file(path: &Path) -> Result<String, String> {
431    std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
432}
433
434/// Reads a file as raw bytes, for content that is fingerprinted rather than rendered. An asset may
435/// be a binary (an image, a font, an archive), so it cannot go through [`read_file`]'s UTF-8
436/// decode — and it never needs to, since only its hash and size reach the IR.
437fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
438    std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
439}
440
441/// Resolves a file reference the same way `prompt_ref` does today (and, by convention,
442/// `eval.template_ref`): relative to the directory that owns it — a component directory for
443/// `prompt_ref`, and, for a future profile-level slot, the profile directory. This is the one
444/// shared rule every resolved-file-reference field should follow, loudly, at compile time:
445///
446/// - the reference must stay inside `base_dir` — an absolute path, or one containing a `..`
447///   segment, is rejected rather than silently escaping `base_dir` (`PathBuf::join` alone allows
448///   both: joining an absolute path replaces the base entirely, and `..` segments are never
449///   normalized away);
450/// - the resolved path must name a file that exists on disk — a missing file is a compile-time
451///   error, never a silent skip.
452///
453/// `label` names the kind of reference in the error message (e.g. `"prompt_ref"`), so this
454/// function stays generic: it never needs to know about `LlmStep`, `EvalSpec`, or any future
455/// slot/asset field.
456///
457/// Two different kinds of future callers are expected to reuse this function unchanged, and they
458/// differ only in what they do with the `Ok(PathBuf)` it returns:
459///
460/// - a **slot variant** reads the resolved file's *content* into the IR, exactly like `prompt_ref`
461///   does today (see the call site in [`compile_project_to_ir_with`]);
462/// - an **asset** only needs the existence check this function already performs — the resolved
463///   path becomes a manifest entry (a file that must exist on disk at render/dispatch time), and
464///   its content is never read into the IR.
465fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
466    let ref_path = Path::new(reference);
467    if ref_path.is_absolute()
468        || ref_path
469            .components()
470            .any(|c| matches!(c, std::path::Component::ParentDir))
471    {
472        return Err(format!(
473            "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
474        ));
475    }
476    let resolved = base_dir.join(ref_path);
477    if !resolved.is_file() {
478        return Err(format!(
479            "{label} '{reference}' does not exist: {}",
480            resolved.display()
481        ));
482    }
483    Ok(resolved)
484}
485
486#[cfg(test)]
487mod resolve_file_ref_tests {
488    use super::resolve_file_ref;
489
490    #[test]
491    fn accepts_a_relative_path_to_an_existing_file() {
492        let dir = tempfile::tempdir().unwrap();
493        std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
494
495        let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
496
497        assert_eq!(resolved, dir.path().join("ask.md"));
498    }
499
500    #[test]
501    fn accepts_a_relative_path_in_a_subdirectory() {
502        let dir = tempfile::tempdir().unwrap();
503        std::fs::create_dir(dir.path().join("steps")).unwrap();
504        std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
505
506        let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
507
508        assert_eq!(resolved, dir.path().join("steps/ask.md"));
509    }
510
511    #[test]
512    fn rejects_a_dotdot_escape() {
513        let dir = tempfile::tempdir().unwrap();
514
515        let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
516
517        assert!(err.contains("prompt_ref"));
518        assert!(err.contains("../secrets.md"));
519        assert!(err.contains(".."));
520    }
521
522    #[test]
523    fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
524        let dir = tempfile::tempdir().unwrap();
525
526        let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
527
528        assert!(err.contains("steps/../../escape.md"));
529    }
530
531    #[test]
532    fn rejects_an_absolute_path() {
533        let dir = tempfile::tempdir().unwrap();
534
535        let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
536
537        assert!(err.contains("prompt_ref"));
538        assert!(err.contains("/etc/passwd"));
539    }
540
541    #[test]
542    fn rejects_a_missing_file() {
543        let dir = tempfile::tempdir().unwrap();
544
545        let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
546
547        assert!(err.contains("prompt_ref"));
548        assert!(err.contains("steps/missing.md"));
549        assert!(err.contains("does not exist"));
550    }
551}
552
553/// Compute the [`warble::BlastRadius`] of `node` in a Warble project's bound wren project. Resolves
554/// the project the same way [`compile_project_to_ir`] does (profile.yml → context binding →
555/// wren project → `MdlContext`), but stops short of a full compile — just the lineage query.
556pub fn blast_radius_for_project(
557    project_dir: &Path,
558    node: &str,
559) -> Result<warble::BlastRadius, String> {
560    blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
561}
562
563/// As [`blast_radius_for_project`], resolving the context binding through a caller-supplied
564/// [`ContextResolver`]. Kept in step with [`compile_project_to_ir_with`] so a host kind is bindable
565/// on both paths — a lineage query over a context only the host can load is no less valid than a
566/// compile over it.
567pub fn blast_radius_for_project_with(
568    project_dir: &Path,
569    node: &str,
570    resolver: &dyn ContextResolver,
571) -> Result<warble::BlastRadius, String> {
572    let profile_path = project_dir.join("profile.yml");
573    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
574        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
575
576    let binding_path = project_dir.join(&profile.context.project);
577    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
578        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
579
580    let context = resolver.resolve(&binding, project_dir)?;
581
582    if !context.is_parseable() {
583        return Err(format!(
584            "bound context '{}' is not parseable — cannot compute blast radius",
585            binding.project
586        ));
587    }
588
589    Ok(context.lineage().blast_radius(node))
590}
591
592#[cfg(test)]
593mod component_source_tests {
594    use super::{resolve_component_dir, ComponentSource};
595    use std::fs;
596    use std::path::Path;
597
598    /// Create `<dir>/<id>/component.yml` (contents are irrelevant — resolution only checks the
599    /// file exists) so a source directory "defines" `id`.
600    fn stub_component(dir: &Path, id: &str) {
601        let component_dir = dir.join(id);
602        fs::create_dir_all(&component_dir).unwrap();
603        fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
604    }
605
606    #[test]
607    fn resolves_from_the_only_source_that_defines_it() {
608        let hub = tempfile::tempdir().unwrap();
609        stub_component(hub.path(), "answer_query");
610
611        let sources = vec![ComponentSource::hub(hub.path())];
612        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
613        assert_eq!(resolved, hub.path().join("answer_query"));
614    }
615
616    #[test]
617    fn local_overrides_hub_when_both_define_the_same_id() {
618        let local = tempfile::tempdir().unwrap();
619        let hub = tempfile::tempdir().unwrap();
620        stub_component(local.path(), "answer_query");
621        stub_component(hub.path(), "answer_query");
622
623        let sources = vec![
624            ComponentSource::local(local.path()),
625            ComponentSource::hub(hub.path()),
626        ];
627        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
628        assert_eq!(
629            resolved,
630            local.path().join("answer_query"),
631            "a Local source must win over a Hub source defining the same id"
632        );
633    }
634
635    #[test]
636    fn falls_back_to_hub_when_local_does_not_define_the_id() {
637        let local = tempfile::tempdir().unwrap();
638        let hub = tempfile::tempdir().unwrap();
639        stub_component(hub.path(), "answer_query");
640
641        let sources = vec![
642            ComponentSource::local(local.path()),
643            ComponentSource::hub(hub.path()),
644        ];
645        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
646        assert_eq!(resolved, hub.path().join("answer_query"));
647    }
648
649    #[test]
650    fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
651        let local = tempfile::tempdir().unwrap();
652        let hub = tempfile::tempdir().unwrap();
653
654        let sources = vec![
655            ComponentSource::local(local.path()),
656            ComponentSource::hub(hub.path()),
657        ];
658        let err = resolve_component_dir(&sources, "missing").unwrap_err();
659        assert!(err.contains("missing"));
660        assert!(err.contains(&local.path().display().to_string()));
661        assert!(err.contains(&hub.path().display().to_string()));
662    }
663
664    #[test]
665    fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
666        let local_a = tempfile::tempdir().unwrap();
667        let local_b = tempfile::tempdir().unwrap();
668        stub_component(local_a.path(), "answer_query");
669        stub_component(local_b.path(), "answer_query");
670
671        let sources = vec![
672            ComponentSource::local(local_a.path()),
673            ComponentSource::local(local_b.path()),
674        ];
675        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
676        assert!(err.contains("ambiguous"));
677        assert!(err.contains(&local_a.path().display().to_string()));
678        assert!(err.contains(&local_b.path().display().to_string()));
679    }
680
681    #[test]
682    fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
683        // Even though `hub` alone would resolve `answer_query` unambiguously, an ambiguous match
684        // at the higher-precedence `Local` kind must fail loud rather than silently falling back.
685        let local_a = tempfile::tempdir().unwrap();
686        let local_b = tempfile::tempdir().unwrap();
687        let hub = tempfile::tempdir().unwrap();
688        stub_component(local_a.path(), "answer_query");
689        stub_component(local_b.path(), "answer_query");
690        stub_component(hub.path(), "answer_query");
691
692        let sources = vec![
693            ComponentSource::local(local_a.path()),
694            ComponentSource::local(local_b.path()),
695            ComponentSource::hub(hub.path()),
696        ];
697        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
698        assert!(err.contains("ambiguous"));
699    }
700}
701
702/// `warble_eval_runner::ComplianceIr` (the type `eval compliance` deserializes into) is
703/// *deliberately* narrower than `WarbleIr` — its own doc comment says so, so a compiled-in
704/// `warble_ir_version` field never belongs on that type. But `eval compliance` is not fed an
705/// arbitrary subset: every real caller hands it the same complete `ir.json` `dispatch`/`manifest`
706/// consume (confirmed by `eval/golden/compliance/ground_truth.yaml`'s own comment — its two golden
707/// IRs, `examples/mutate-agent/ir.golden.json` and `examples/analysis-agent/ir.golden.json`, are "reused
708/// as-is, not new fixtures"). So the version gate belongs here, at the CLI boundary, checked on the
709/// raw JSON before `ComplianceIr` ever sees it — against the same
710/// `warble_claude_code::ir::SUPPORTED_IR_VERSION` that the binary's own `load_ir` already gates
711/// `dispatch`/`manifest` against, so every CLI-level IR consumer rejects an out-of-range version
712/// the same way.
713pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
714    let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
715        format!(
716            "failed to parse IR {} for version check: {e}",
717            path.display()
718        )
719    })?;
720    // Absent and present-but-not-a-string are reported separately on purpose: collapsing them sends
721    // someone whose IR *does* carry the field looking for a missing key that is right there.
722    match parsed.get("warble_ir_version") {
723        None => Err(format!(
724            "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
725             IR, not a hand-written subset",
726            path.display()
727        )),
728        Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
729        Some(serde_json::Value::String(v)) => Err(format!(
730            "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
731            path.display()
732        )),
733        Some(other) => Err(format!(
734            "warble_ir_version in {} is {}, not a string — eval compliance understands: \
735             {SUPPORTED_IR_VERSION}",
736            path.display(),
737            match other {
738                serde_json::Value::Null => "null",
739                serde_json::Value::Bool(_) => "a boolean",
740                serde_json::Value::Number(_) => "a number",
741                serde_json::Value::Array(_) => "an array",
742                _ => "an object",
743            }
744        )),
745    }
746}
747
748#[cfg(test)]
749mod compliance_ir_version_tests {
750    use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
751    use std::path::Path;
752
753    fn check(raw: &str) -> Result<(), String> {
754        check_compliance_ir_version(raw, Path::new("ir.json"))
755    }
756
757    #[test]
758    fn the_supported_version_passes() {
759        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
760        assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
761    }
762
763    #[test]
764    fn a_missing_field_is_reported_as_missing() {
765        let err = check(r#"{"components": []}"#).expect_err("must reject");
766        assert!(err.contains("has no warble_ir_version"), "got: {err}");
767    }
768
769    #[test]
770    fn an_out_of_range_version_is_reported_as_unsupported() {
771        let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
772        assert!(
773            err.contains("unsupported warble_ir_version '0.2'"),
774            "got: {err}"
775        );
776        assert!(
777            err.contains(SUPPORTED_IR_VERSION),
778            "must name what it does accept: {err}"
779        );
780    }
781
782    #[test]
783    fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
784        // Guards against a `starts_with` regression: "0.30" is not "0.3".
785        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
786        let err = check(&raw).expect_err("a superstring is a different version");
787        assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
788    }
789
790    /// The matrix this module exists for: every non-string JSON type must be reported as a *type*
791    /// error naming what it actually is — never as "the field is missing", which would send someone
792    /// looking for a key that is sitting right there. `null` is the one that regressed before.
793    #[test]
794    fn every_non_string_type_is_reported_as_a_type_error() {
795        for (literal, expected) in [
796            ("null", "is null"),
797            ("true", "is a boolean"),
798            ("3", "is a number"),
799            ("[]", "is an array"),
800            ("{}", "is an object"),
801        ] {
802            let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
803            let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
804            assert!(
805                err.contains(expected),
806                "for {literal} expected '{expected}', got: {err}"
807            );
808            assert!(
809                !err.contains("has no warble_ir_version"),
810                "for {literal} the field is present, not missing: {err}"
811            );
812        }
813    }
814
815    #[test]
816    fn unparseable_json_is_reported_as_a_parse_failure() {
817        let err = check("not json at all").expect_err("must reject");
818        assert!(err.contains("failed to parse IR"), "got: {err}");
819    }
820}