pub mod gate;
pub mod hub_fetch;
pub mod overlay;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use warble::{
read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile,
RawSourceContext,
};
use warble_claude_code::ir::SUPPORTED_IR_VERSION;
use warble_mdl_context::{read_project_dir, MdlContext};
#[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)))
}
BindingFile::PREPARED => {
let document_ref = binding
.extra
.get("document")
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!(
"binding declares `kind: {}` but no `document:` — add the path to the \
prepared-context document the host wrote, and keep `project:` as the \
bound layer's name",
BindingFile::PREPARED
)
})?;
let document_path = project_dir.join(document_ref);
let document = std::fs::read_to_string(&document_path)
.map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
let context = PreparedContext::from_json(&document)
.map_err(|e| format!("{}: {e}", document_path.display()))?;
Ok(Box::new(context))
}
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,
BindingFile::PREPARED
)),
}
}
}
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> {
compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
}
pub fn compile_project_to_ir_with_overlay(
project_dir: &Path,
sources: &[ComponentSource],
resolver: &dyn ContextResolver,
overlay_path: Option<&Path>,
) -> Result<serde_json::Value, String> {
let profile_path = project_dir.join("profile.yml");
let mut profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
.map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
if let Some(path) = overlay_path {
let overlay = overlay::read_overlay(path)?;
overlay::apply_overlay(&mut profile, &overlay)?;
}
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();
let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
for slot in &profile.slots {
let mut variants: HashMap<String, String> = HashMap::new();
for (key, reference) in &slot.variants {
let label = format!("profile slot '{}' variant '{}'", slot.name, key);
let variant_path = resolve_file_ref(project_dir, reference, &label)?;
variants.insert(key.clone(), read_file(&variant_path)?);
}
profile_slots.insert(slot.name.clone(), variants);
}
let mut slot_contents = warble::SlotContents {
profile: profile_slots,
..Default::default()
};
for mount in &profile.components {
let component_dir = resolve_component_dir(sources, &mount.use_id)?;
let component_path = component_dir.join("component.yml");
let mut 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 = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
steps.insert(step.name.clone(), read_file(&step_path)?);
}
step_contents.insert(component.id.clone(), steps);
let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
for slot in &component.slots {
let mut variants: HashMap<String, String> = HashMap::new();
for (key, reference) in &slot.variants {
let label = format!("slot '{}' variant '{}'", slot.name, key);
let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
variants.insert(key.clone(), read_file(&variant_path)?);
}
slots.insert(slot.name.clone(), variants);
}
if !slots.is_empty() {
slot_contents.components.insert(component.id.clone(), slots);
}
for asset in &mut component.assets {
let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
let data = read_file_bytes(&asset_path)?;
asset.bytes = Some(data.len() as u64);
asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
}
components.insert(component.id.clone(), component);
}
warble::compile(
&profile,
&components,
&binding.project,
context.as_ref(),
&step_contents,
&slot_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()))
}
fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
let ref_path = Path::new(reference);
if ref_path.is_absolute()
|| ref_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
));
}
let resolved = base_dir.join(ref_path);
if !resolved.is_file() {
return Err(format!(
"{label} '{reference}' does not exist: {}",
resolved.display()
));
}
Ok(resolved)
}
#[cfg(test)]
mod resolve_file_ref_tests {
use super::resolve_file_ref;
#[test]
fn accepts_a_relative_path_to_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
assert_eq!(resolved, dir.path().join("ask.md"));
}
#[test]
fn accepts_a_relative_path_in_a_subdirectory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("steps")).unwrap();
std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
assert_eq!(resolved, dir.path().join("steps/ask.md"));
}
#[test]
fn rejects_a_dotdot_escape() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("../secrets.md"));
assert!(err.contains(".."));
}
#[test]
fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
assert!(err.contains("steps/../../escape.md"));
}
#[test]
fn rejects_an_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("/etc/passwd"));
}
#[test]
fn rejects_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("steps/missing.md"));
assert!(err.contains("does not exist"));
}
}
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}");
}
}