pub mod gate;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
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")
}
pub fn default_component_sources(project_dir: &Path) -> Vec<ComponentSource> {
vec![
ComponentSource::local(project_dir.join("components")),
ComponentSource::hub(in_repo_hub_dir()),
]
}
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("; ")
))
}
fn resolve_context(resolved_project_path: &Path) -> Result<Box<dyn ContextLoader>, String> {
if let Some(sources) = read_project_dir(resolved_project_path)
.map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
{
let context = match MdlContext::try_from_sources(&sources) {
Ok(ctx) => ctx,
Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
};
return Ok(Box::new(context));
}
if let Some(raw) = read_raw_dir(resolved_project_path)
.map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
{
return Ok(Box::new(RawSourceContext::from_sources(&raw)));
}
Ok(Box::new(MdlContext::unparseable()))
}
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> {
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 resolved_project_path = project_dir.join(&binding.project);
let context = resolve_context(&resolved_project_path)?;
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> {
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 resolved_project_path = project_dir.join(&binding.project);
let context = resolve_context(&resolved_project_path)?;
if !context.is_parseable() {
return Err(format!(
"wren project at {} is not parseable — cannot compute blast radius",
resolved_project_path.display()
));
}
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"));
}
}