pub mod gate;
pub mod hub_fetch;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
use warble_claude_code::ir::SUPPORTED_IR_VERSION;
use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Local,
Hub,
}
impl fmt::Display for SourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
SourceKind::Local => "local",
SourceKind::Hub => "hub",
})
}
}
#[derive(Debug, Clone)]
pub struct ComponentSource {
pub kind: SourceKind,
pub components_dir: PathBuf,
}
impl ComponentSource {
pub fn local(components_dir: impl Into<PathBuf>) -> Self {
ComponentSource {
kind: SourceKind::Local,
components_dir: components_dir.into(),
}
}
pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
ComponentSource {
kind: SourceKind::Hub,
components_dir: components_dir.into(),
}
}
fn candidate(&self, id: &str) -> PathBuf {
self.components_dir.join(id)
}
}
fn in_repo_hub_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("hub")
.join("components")
}
fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
let in_repo = in_repo_hub_dir();
if in_repo.is_dir() {
return Ok(ComponentSource::hub(in_repo));
}
let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
let cached_dir = hub_fetch::ensure_cached_hub(version)?;
Ok(ComponentSource::hub(cached_dir))
}
pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
default_component_sources_with_hub_version(project_dir, None)
}
pub fn default_component_sources_with_hub_version(
project_dir: &Path,
hub_version: Option<&str>,
) -> Result<Vec<ComponentSource>, String> {
Ok(vec![
ComponentSource::local(project_dir.join("components")),
default_hub_source(hub_version)?,
])
}
fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
for kind in [SourceKind::Local, SourceKind::Hub] {
let matches: Vec<&ComponentSource> = sources
.iter()
.filter(|source| source.kind == kind)
.filter(|source| source.candidate(id).join("component.yml").is_file())
.collect();
match matches.as_slice() {
[] => continue,
[only] => return Ok(only.candidate(id)),
many => {
let dirs: Vec<String> = many
.iter()
.map(|s| s.candidate(id).display().to_string())
.collect();
return Err(format!(
"component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
precedence rule distinguishes sources of the same kind",
many.len(),
dirs.join(", ")
));
}
}
}
let searched: Vec<String> = sources
.iter()
.map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
.collect();
Err(format!(
"component '{id}' not found in any configured source: {}",
searched.join("; ")
))
}
pub trait ContextResolver {
fn resolve(
&self,
binding: &BindingFile,
project_dir: &Path,
) -> Result<Box<dyn ContextLoader>, String>;
}
pub struct BuiltinContextResolver;
impl ContextResolver for BuiltinContextResolver {
fn resolve(
&self,
binding: &BindingFile,
project_dir: &Path,
) -> Result<Box<dyn ContextLoader>, String> {
if binding.kind == BindingFile::EXTERNAL {
return Ok(Box::new(warble::ExternalContext::new()));
}
let path = project_dir.join(&binding.project);
match binding.kind.as_str() {
BindingFile::WREN_PROJECT => {
if let Some(sources) = read_project_dir(&path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?
{
return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
Ok(ctx) => ctx,
Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
}));
}
if path.join("schema.json").is_file() {
return Err(format!(
"{} holds a raw source (schema.json), not a wren project — declare \
`kind: {}` in the binding to bind it",
path.display(),
BindingFile::RAW_SOURCE
));
}
Ok(Box::new(MdlContext::unparseable()))
}
BindingFile::RAW_SOURCE => {
let raw = read_raw_dir(&path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?
.ok_or_else(|| {
format!(
"binding declares `kind: {}` but {} has no schema.json",
BindingFile::RAW_SOURCE,
path.display()
)
})?;
Ok(Box::new(RawSourceContext::from_sources(&raw)))
}
other => Err(format!(
"unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A host \
that defines '{other}' must supply a ContextResolver for it — see \
`compile_project_to_ir_with`.",
BindingFile::WREN_PROJECT,
BindingFile::RAW_SOURCE,
BindingFile::EXTERNAL
)),
}
}
}
pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
}
pub fn compile_project_to_ir_with_sources(
project_dir: &Path,
sources: &[ComponentSource],
) -> Result<serde_json::Value, String> {
compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
}
pub fn compile_project_to_ir_with(
project_dir: &Path,
sources: &[ComponentSource],
resolver: &dyn ContextResolver,
) -> Result<serde_json::Value, String> {
let profile_path = project_dir.join("profile.yml");
let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
.map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
let binding_path = project_dir.join(&profile.context.project);
let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
.map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
let context = resolver.resolve(&binding, project_dir)?;
let mut components: HashMap<String, ComponentFile> = HashMap::new();
let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
for mount in &profile.components {
let component_dir = resolve_component_dir(sources, &mount.use_id)?;
let component_path = component_dir.join("component.yml");
let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
.map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
let mut steps: HashMap<String, String> = HashMap::new();
for step in &component.llm_steps {
let step_path = component_dir.join(&step.prompt_ref);
steps.insert(step.name.clone(), read_file(&step_path)?);
}
step_contents.insert(component.id.clone(), steps);
components.insert(component.id.clone(), component);
}
warble::compile(
&profile,
&components,
&binding.project,
context.as_ref(),
&step_contents,
)
.map_err(|e| e.to_string())
}
fn read_file(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
pub fn blast_radius_for_project(
project_dir: &Path,
node: &str,
) -> Result<warble::BlastRadius, String> {
blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
}
pub fn blast_radius_for_project_with(
project_dir: &Path,
node: &str,
resolver: &dyn ContextResolver,
) -> Result<warble::BlastRadius, String> {
let profile_path = project_dir.join("profile.yml");
let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
.map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
let binding_path = project_dir.join(&profile.context.project);
let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
.map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
let context = resolver.resolve(&binding, project_dir)?;
if !context.is_parseable() {
return Err(format!(
"bound context '{}' is not parseable — cannot compute blast radius",
binding.project
));
}
Ok(context.lineage().blast_radius(node))
}
#[cfg(test)]
mod component_source_tests {
use super::{resolve_component_dir, ComponentSource};
use std::fs;
use std::path::Path;
fn stub_component(dir: &Path, id: &str) {
let component_dir = dir.join(id);
fs::create_dir_all(&component_dir).unwrap();
fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
}
#[test]
fn resolves_from_the_only_source_that_defines_it() {
let hub = tempfile::tempdir().unwrap();
stub_component(hub.path(), "answer_query");
let sources = vec![ComponentSource::hub(hub.path())];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(resolved, hub.path().join("answer_query"));
}
#[test]
fn local_overrides_hub_when_both_define_the_same_id() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(local.path(), "answer_query");
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(
resolved,
local.path().join("answer_query"),
"a Local source must win over a Hub source defining the same id"
);
}
#[test]
fn falls_back_to_hub_when_local_does_not_define_the_id() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(resolved, hub.path().join("answer_query"));
}
#[test]
fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let err = resolve_component_dir(&sources, "missing").unwrap_err();
assert!(err.contains("missing"));
assert!(err.contains(&local.path().display().to_string()));
assert!(err.contains(&hub.path().display().to_string()));
}
#[test]
fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
let local_a = tempfile::tempdir().unwrap();
let local_b = tempfile::tempdir().unwrap();
stub_component(local_a.path(), "answer_query");
stub_component(local_b.path(), "answer_query");
let sources = vec![
ComponentSource::local(local_a.path()),
ComponentSource::local(local_b.path()),
];
let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
assert!(err.contains("ambiguous"));
assert!(err.contains(&local_a.path().display().to_string()));
assert!(err.contains(&local_b.path().display().to_string()));
}
#[test]
fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
let local_a = tempfile::tempdir().unwrap();
let local_b = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(local_a.path(), "answer_query");
stub_component(local_b.path(), "answer_query");
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local_a.path()),
ComponentSource::local(local_b.path()),
ComponentSource::hub(hub.path()),
];
let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
assert!(err.contains("ambiguous"));
}
}
pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
format!(
"failed to parse IR {} for version check: {e}",
path.display()
)
})?;
match parsed.get("warble_ir_version") {
None => Err(format!(
"IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
IR, not a hand-written subset",
path.display()
)),
Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
Some(serde_json::Value::String(v)) => Err(format!(
"unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
path.display()
)),
Some(other) => Err(format!(
"warble_ir_version in {} is {}, not a string — eval compliance understands: \
{SUPPORTED_IR_VERSION}",
path.display(),
match other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
_ => "an object",
}
)),
}
}
#[cfg(test)]
mod compliance_ir_version_tests {
use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
use std::path::Path;
fn check(raw: &str) -> Result<(), String> {
check_compliance_ir_version(raw, Path::new("ir.json"))
}
#[test]
fn the_supported_version_passes() {
let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
}
#[test]
fn a_missing_field_is_reported_as_missing() {
let err = check(r#"{"components": []}"#).expect_err("must reject");
assert!(err.contains("has no warble_ir_version"), "got: {err}");
}
#[test]
fn an_out_of_range_version_is_reported_as_unsupported() {
let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
assert!(
err.contains("unsupported warble_ir_version '0.2'"),
"got: {err}"
);
assert!(
err.contains(SUPPORTED_IR_VERSION),
"must name what it does accept: {err}"
);
}
#[test]
fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
let err = check(&raw).expect_err("a superstring is a different version");
assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
}
#[test]
fn every_non_string_type_is_reported_as_a_type_error() {
for (literal, expected) in [
("null", "is null"),
("true", "is a boolean"),
("3", "is a number"),
("[]", "is an array"),
("{}", "is an object"),
] {
let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
assert!(
err.contains(expected),
"for {literal} expected '{expected}', got: {err}"
);
assert!(
!err.contains("has no warble_ir_version"),
"for {literal} the field is present, not missing: {err}"
);
}
}
#[test]
fn unparseable_json_is_reported_as_a_parse_failure() {
let err = check("not json at all").expect_err("must reject");
assert!(err.contains("failed to parse IR"), "got: {err}");
}
}