Skip to main content

warble_cli/
lib.rs

1//! `warble_cli` — the native host library behind the `warble` binary.
2//!
3//! Holds the pieces the binary and the golden integration tests share. The headline is
4//! [`compile_project_to_ir`]: the real front-end host path — read a Warble project's files, build
5//! the MDL [`ContextLoader`] over the bound wren project, and run the sans-IO core compiler with it
6//! injected. (The binary's `dispatch`/`render`/`manifest`/`eval` subcommands stay in `main.rs`.)
7//!
8//! [`blast_radius_for_project`] reuses the same project-resolution path to answer a `blast_radius`
9//! query without running a full compile — the host side of the `warble blast-radius` subcommand.
10//!
11//! Component *resolution* — turning a profile's `use: <id>` mount into a directory to read
12//! `component.yml`/steps from — is entirely a host concern (see [`ComponentSource`] /
13//! `resolve_component_dir`): the core compiler never touches a filesystem, it only ever sees the
14//! already-resolved `HashMap<String, ComponentFile>` this module builds.
15//!
16//! This is the crate that becomes the `warble` binary itself — end users install just this one.
17//! It links in both dispatcher back-ends (`warble-claude-code`, `warble-vercel`) and the
18//! `warble-mdl-context` binding directly; none of those three crates is a standalone tool, and
19//! `warble dispatch --target ...` simply selects which linked-in back-end handles the compiled
20//! IR.
21
22pub mod gate;
23
24use std::collections::HashMap;
25use std::fmt;
26use std::path::{Path, PathBuf};
27
28use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
29use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
30
31/// The precedence class a [`ComponentSource`] belongs to. Precedence is a fixed rule *between*
32/// kinds — `Local` always outranks `Hub` — not derived from the order sources happen to be listed
33/// in. There is deliberately no rule *within* a kind: if the same component id is found in two
34/// sources of the same kind, `resolve_component_dir` treats that as ambiguous rather than
35/// guessing (e.g. "first in the list wins"), because nothing declares which of two equally-ranked
36/// sources should win.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SourceKind {
39    /// A host- or product-specific component directory — e.g. a profile's own `components/`
40    /// override, or an external component library a consumer mounts alongside the Hub. Wins over
41    /// `Hub` whenever both define the same id.
42    Local,
43    /// The shared, generic Warble component library (this checkout's `hub/components`, or an
44    /// externally-supplied equivalent). The fallback tier: consulted only for ids no `Local`
45    /// source defines.
46    Hub,
47}
48
49impl fmt::Display for SourceKind {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(match self {
52            SourceKind::Local => "local",
53            SourceKind::Hub => "hub",
54        })
55    }
56}
57
58/// One explicitly-configured place a mounted component's files may live: `components_dir` is a
59/// directory whose immediate children are `<id>/component.yml` (+ its `steps/*`). The host builds
60/// the full ordered/kinded list up front (see [`compile_project_to_ir_with_sources`]) — resolution
61/// never discovers sources on its own (no ancestor/filesystem-root walk).
62#[derive(Debug, Clone)]
63pub struct ComponentSource {
64    pub kind: SourceKind,
65    pub components_dir: PathBuf,
66}
67
68impl ComponentSource {
69    pub fn local(components_dir: impl Into<PathBuf>) -> Self {
70        ComponentSource {
71            kind: SourceKind::Local,
72            components_dir: components_dir.into(),
73        }
74    }
75
76    pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
77        ComponentSource {
78            kind: SourceKind::Hub,
79            components_dir: components_dir.into(),
80        }
81    }
82
83    fn candidate(&self, id: &str) -> PathBuf {
84        self.components_dir.join(id)
85    }
86}
87
88/// This checkout's own Hub component library — a fixed sibling of the `cli` crate's manifest dir,
89/// known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking the filesystem at
90/// runtime. This is what backs [`compile_project_to_ir`]'s default source list, so every in-repo
91/// example/eval profile keeps resolving its Hub-mounted components exactly as before.
92fn in_repo_hub_dir() -> PathBuf {
93    Path::new(env!("CARGO_MANIFEST_DIR"))
94        .join("..")
95        .join("hub")
96        .join("components")
97}
98
99/// The default two-source list used by [`compile_project_to_ir`]: the project's own `components/`
100/// (`Local`, highest precedence — lets a profile deliberately diverge from the Hub, e.g. an
101/// eval/demo substrate with intentionally different anatomy) plus this checkout's Hub (`Hub`,
102/// fallback). A host mounting an *additional* local component library (e.g. a product-specific
103/// one, alongside the Hub) extends this list — see [`compile_project_to_ir_with_sources`].
104pub fn default_component_sources(project_dir: &Path) -> Vec<ComponentSource> {
105    vec![
106        ComponentSource::local(project_dir.join("components")),
107        ComponentSource::hub(in_repo_hub_dir()),
108    ]
109}
110
111/// Resolve where a mounted component's directory lives, against an explicit, ordered set of
112/// sources — never by walking the filesystem. Within the highest-precedence [`SourceKind`] that
113/// contains a match, exactly one source must define `id`; more than one is **ambiguous** (loud
114/// fail — no rule picks between equally-ranked sources) and zero across every source is
115/// **unresolved** (loud fail, listing everywhere it looked).
116fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
117    for kind in [SourceKind::Local, SourceKind::Hub] {
118        let matches: Vec<&ComponentSource> = sources
119            .iter()
120            .filter(|source| source.kind == kind)
121            .filter(|source| source.candidate(id).join("component.yml").is_file())
122            .collect();
123
124        match matches.as_slice() {
125            [] => continue,
126            [only] => return Ok(only.candidate(id)),
127            many => {
128                let dirs: Vec<String> = many
129                    .iter()
130                    .map(|s| s.candidate(id).display().to_string())
131                    .collect();
132                return Err(format!(
133                    "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
134                     precedence rule distinguishes sources of the same kind",
135                    many.len(),
136                    dirs.join(", ")
137                ));
138            }
139        }
140    }
141
142    let searched: Vec<String> = sources
143        .iter()
144        .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
145        .collect();
146    Err(format!(
147        "component '{id}' not found in any configured source: {}",
148        searched.join("; ")
149    ))
150}
151
152/// Resolve the `ContextLoader` for a bound project path by directory *shape*: an MDL wren project
153/// (`wren_project.yml`) wins first; else a raw source (`schema.json`) — the constitutive family's
154/// pre-MDL input; else an unparseable MDL context (existing fallback, loud-fails at compile).
155fn resolve_context(resolved_project_path: &Path) -> Result<Box<dyn ContextLoader>, String> {
156    if let Some(sources) = read_project_dir(resolved_project_path)
157        .map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
158    {
159        // Use the error-preserving `try_from_sources` (not `from_sources`) so a real assembly
160        // failure's text survives into the `mdl_parseable` precondition message instead of being
161        // silently dropped in favor of only the generic floor message.
162        let context = match MdlContext::try_from_sources(&sources) {
163            Ok(ctx) => ctx,
164            Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
165        };
166        return Ok(Box::new(context));
167    }
168    if let Some(raw) = read_raw_dir(resolved_project_path)
169        .map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
170    {
171        return Ok(Box::new(RawSourceContext::from_sources(&raw)));
172    }
173    Ok(Box::new(MdlContext::unparseable()))
174}
175
176/// Compile a Warble project directory into its IR JSON, using the real MDL `ContextLoader` over the
177/// bound wren project and the default component source list (project-local `components/` + this
178/// checkout's Hub — see [`default_component_sources`]). This is what every in-repo example/eval
179/// profile and integration test compiles through.
180pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
181    compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir))
182}
183
184/// Compile a Warble project directory into its IR JSON, resolving mounted components against an
185/// explicit, caller-supplied source list instead of the in-repo default (see [`ComponentSource`]).
186/// This is the seam a host outside this checkout uses to mount its own local component library
187/// alongside (or instead of) the Hub — e.g. `[ComponentSource::local(my_components),
188/// ComponentSource::hub(path_to_warble_hub)]`. All filesystem reads happen here; the core compiler
189/// stays sans-IO — it only ever receives the resolved `HashMap<String, ComponentFile>` built below.
190pub fn compile_project_to_ir_with_sources(
191    project_dir: &Path,
192    sources: &[ComponentSource],
193) -> Result<serde_json::Value, String> {
194    let profile_path = project_dir.join("profile.yml");
195    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
196        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
197
198    let binding_path = project_dir.join(&profile.context.project);
199    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
200        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
201
202    // Build the ContextLoader over the bound path, by shape: an MDL wren project, else a raw source
203    // (constitutive family), else an unparseable context — which the compiler turns into a loud
204    // precondition failure.
205    let resolved_project_path = project_dir.join(&binding.project);
206    let context = resolve_context(&resolved_project_path)?;
207
208    let mut components: HashMap<String, ComponentFile> = HashMap::new();
209    let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
210
211    for mount in &profile.components {
212        let component_dir = resolve_component_dir(sources, &mount.use_id)?;
213        let component_path = component_dir.join("component.yml");
214        let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
215            .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
216
217        let mut steps: HashMap<String, String> = HashMap::new();
218        for step in &component.llm_steps {
219            let step_path = component_dir.join(&step.prompt_ref);
220            steps.insert(step.name.clone(), read_file(&step_path)?);
221        }
222        step_contents.insert(component.id.clone(), steps);
223        components.insert(component.id.clone(), component);
224    }
225
226    warble::compile(
227        &profile,
228        &components,
229        &binding.project,
230        context.as_ref(),
231        &step_contents,
232    )
233    .map_err(|e| e.to_string())
234}
235
236fn read_file(path: &Path) -> Result<String, String> {
237    std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
238}
239
240/// Compute the [`warble::BlastRadius`] of `node` in a Warble project's bound wren project. Resolves
241/// the project the same way [`compile_project_to_ir`] does (profile.yml → context binding →
242/// wren project → `MdlContext`), but stops short of a full compile — just the lineage query.
243pub fn blast_radius_for_project(
244    project_dir: &Path,
245    node: &str,
246) -> Result<warble::BlastRadius, String> {
247    let profile_path = project_dir.join("profile.yml");
248    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
249        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
250
251    let binding_path = project_dir.join(&profile.context.project);
252    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
253        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
254
255    let resolved_project_path = project_dir.join(&binding.project);
256    let context = resolve_context(&resolved_project_path)?;
257
258    if !context.is_parseable() {
259        return Err(format!(
260            "wren project at {} is not parseable — cannot compute blast radius",
261            resolved_project_path.display()
262        ));
263    }
264
265    Ok(context.lineage().blast_radius(node))
266}
267
268#[cfg(test)]
269mod component_source_tests {
270    use super::{resolve_component_dir, ComponentSource};
271    use std::fs;
272    use std::path::Path;
273
274    /// Create `<dir>/<id>/component.yml` (contents are irrelevant — resolution only checks the
275    /// file exists) so a source directory "defines" `id`.
276    fn stub_component(dir: &Path, id: &str) {
277        let component_dir = dir.join(id);
278        fs::create_dir_all(&component_dir).unwrap();
279        fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
280    }
281
282    #[test]
283    fn resolves_from_the_only_source_that_defines_it() {
284        let hub = tempfile::tempdir().unwrap();
285        stub_component(hub.path(), "answer_query");
286
287        let sources = vec![ComponentSource::hub(hub.path())];
288        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
289        assert_eq!(resolved, hub.path().join("answer_query"));
290    }
291
292    #[test]
293    fn local_overrides_hub_when_both_define_the_same_id() {
294        let local = tempfile::tempdir().unwrap();
295        let hub = tempfile::tempdir().unwrap();
296        stub_component(local.path(), "answer_query");
297        stub_component(hub.path(), "answer_query");
298
299        let sources = vec![
300            ComponentSource::local(local.path()),
301            ComponentSource::hub(hub.path()),
302        ];
303        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
304        assert_eq!(
305            resolved,
306            local.path().join("answer_query"),
307            "a Local source must win over a Hub source defining the same id"
308        );
309    }
310
311    #[test]
312    fn falls_back_to_hub_when_local_does_not_define_the_id() {
313        let local = tempfile::tempdir().unwrap();
314        let hub = tempfile::tempdir().unwrap();
315        stub_component(hub.path(), "answer_query");
316
317        let sources = vec![
318            ComponentSource::local(local.path()),
319            ComponentSource::hub(hub.path()),
320        ];
321        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
322        assert_eq!(resolved, hub.path().join("answer_query"));
323    }
324
325    #[test]
326    fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
327        let local = tempfile::tempdir().unwrap();
328        let hub = tempfile::tempdir().unwrap();
329
330        let sources = vec![
331            ComponentSource::local(local.path()),
332            ComponentSource::hub(hub.path()),
333        ];
334        let err = resolve_component_dir(&sources, "missing").unwrap_err();
335        assert!(err.contains("missing"));
336        assert!(err.contains(&local.path().display().to_string()));
337        assert!(err.contains(&hub.path().display().to_string()));
338    }
339
340    #[test]
341    fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
342        let local_a = tempfile::tempdir().unwrap();
343        let local_b = tempfile::tempdir().unwrap();
344        stub_component(local_a.path(), "answer_query");
345        stub_component(local_b.path(), "answer_query");
346
347        let sources = vec![
348            ComponentSource::local(local_a.path()),
349            ComponentSource::local(local_b.path()),
350        ];
351        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
352        assert!(err.contains("ambiguous"));
353        assert!(err.contains(&local_a.path().display().to_string()));
354        assert!(err.contains(&local_b.path().display().to_string()));
355    }
356
357    #[test]
358    fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
359        // Even though `hub` alone would resolve `answer_query` unambiguously, an ambiguous match
360        // at the higher-precedence `Local` kind must fail loud rather than silently falling back.
361        let local_a = tempfile::tempdir().unwrap();
362        let local_b = tempfile::tempdir().unwrap();
363        let hub = tempfile::tempdir().unwrap();
364        stub_component(local_a.path(), "answer_query");
365        stub_component(local_b.path(), "answer_query");
366        stub_component(hub.path(), "answer_query");
367
368        let sources = vec![
369            ComponentSource::local(local_a.path()),
370            ComponentSource::local(local_b.path()),
371            ComponentSource::hub(hub.path()),
372        ];
373        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
374        assert!(err.contains("ambiguous"));
375    }
376}