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