1pub mod gate;
23pub mod hub_fetch;
24
25use std::collections::HashMap;
26use std::fmt;
27use std::path::{Path, PathBuf};
28
29use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
30use warble_claude_code::ir::SUPPORTED_IR_VERSION;
31use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SourceKind {
41 Local,
45 Hub,
49}
50
51impl fmt::Display for SourceKind {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.write_str(match self {
54 SourceKind::Local => "local",
55 SourceKind::Hub => "hub",
56 })
57 }
58}
59
60#[derive(Debug, Clone)]
65pub struct ComponentSource {
66 pub kind: SourceKind,
67 pub components_dir: PathBuf,
68}
69
70impl ComponentSource {
71 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
72 ComponentSource {
73 kind: SourceKind::Local,
74 components_dir: components_dir.into(),
75 }
76 }
77
78 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
79 ComponentSource {
80 kind: SourceKind::Hub,
81 components_dir: components_dir.into(),
82 }
83 }
84
85 fn candidate(&self, id: &str) -> PathBuf {
86 self.components_dir.join(id)
87 }
88}
89
90fn in_repo_hub_dir() -> PathBuf {
95 Path::new(env!("CARGO_MANIFEST_DIR"))
96 .join("..")
97 .join("hub")
98 .join("components")
99}
100
101fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
113 let in_repo = in_repo_hub_dir();
114 if in_repo.is_dir() {
115 return Ok(ComponentSource::hub(in_repo));
116 }
117 let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
118 let cached_dir = hub_fetch::ensure_cached_hub(version)?;
119 Ok(ComponentSource::hub(cached_dir))
120}
121
122pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
133 default_component_sources_with_hub_version(project_dir, None)
134}
135
136pub fn default_component_sources_with_hub_version(
141 project_dir: &Path,
142 hub_version: Option<&str>,
143) -> Result<Vec<ComponentSource>, String> {
144 Ok(vec![
145 ComponentSource::local(project_dir.join("components")),
146 default_hub_source(hub_version)?,
147 ])
148}
149
150fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
156 for kind in [SourceKind::Local, SourceKind::Hub] {
157 let matches: Vec<&ComponentSource> = sources
158 .iter()
159 .filter(|source| source.kind == kind)
160 .filter(|source| source.candidate(id).join("component.yml").is_file())
161 .collect();
162
163 match matches.as_slice() {
164 [] => continue,
165 [only] => return Ok(only.candidate(id)),
166 many => {
167 let dirs: Vec<String> = many
168 .iter()
169 .map(|s| s.candidate(id).display().to_string())
170 .collect();
171 return Err(format!(
172 "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
173 precedence rule distinguishes sources of the same kind",
174 many.len(),
175 dirs.join(", ")
176 ));
177 }
178 }
179 }
180
181 let searched: Vec<String> = sources
182 .iter()
183 .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
184 .collect();
185 Err(format!(
186 "component '{id}' not found in any configured source: {}",
187 searched.join("; ")
188 ))
189}
190
191pub trait ContextResolver {
202 fn resolve(
205 &self,
206 binding: &BindingFile,
207 project_dir: &Path,
208 ) -> Result<Box<dyn ContextLoader>, String>;
209}
210
211pub struct BuiltinContextResolver;
214
215impl ContextResolver for BuiltinContextResolver {
216 fn resolve(
217 &self,
218 binding: &BindingFile,
219 project_dir: &Path,
220 ) -> Result<Box<dyn ContextLoader>, String> {
221 if binding.kind == BindingFile::EXTERNAL {
225 return Ok(Box::new(warble::ExternalContext::new()));
226 }
227 let path = project_dir.join(&binding.project);
231 match binding.kind.as_str() {
232 BindingFile::WREN_PROJECT => {
233 if let Some(sources) = read_project_dir(&path)
234 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
235 {
236 return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
240 Ok(ctx) => ctx,
241 Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
242 }));
243 }
244 if path.join("schema.json").is_file() {
248 return Err(format!(
249 "{} holds a raw source (schema.json), not a wren project — declare \
250 `kind: {}` in the binding to bind it",
251 path.display(),
252 BindingFile::RAW_SOURCE
253 ));
254 }
255 Ok(Box::new(MdlContext::unparseable()))
258 }
259 BindingFile::RAW_SOURCE => {
260 let raw = read_raw_dir(&path)
261 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
262 .ok_or_else(|| {
263 format!(
264 "binding declares `kind: {}` but {} has no schema.json",
265 BindingFile::RAW_SOURCE,
266 path.display()
267 )
268 })?;
269 Ok(Box::new(RawSourceContext::from_sources(&raw)))
270 }
271 other => Err(format!(
272 "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A host \
273 that defines '{other}' must supply a ContextResolver for it — see \
274 `compile_project_to_ir_with`.",
275 BindingFile::WREN_PROJECT,
276 BindingFile::RAW_SOURCE,
277 BindingFile::EXTERNAL
278 )),
279 }
280 }
281}
282
283pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
288 compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
289}
290
291pub fn compile_project_to_ir_with_sources(
298 project_dir: &Path,
299 sources: &[ComponentSource],
300) -> Result<serde_json::Value, String> {
301 compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
302}
303
304pub fn compile_project_to_ir_with(
309 project_dir: &Path,
310 sources: &[ComponentSource],
311 resolver: &dyn ContextResolver,
312) -> Result<serde_json::Value, String> {
313 let profile_path = project_dir.join("profile.yml");
314 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
315 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
316
317 let binding_path = project_dir.join(&profile.context.project);
318 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
319 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
320
321 let context = resolver.resolve(&binding, project_dir)?;
322
323 let mut components: HashMap<String, ComponentFile> = HashMap::new();
324 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
325
326 for mount in &profile.components {
327 let component_dir = resolve_component_dir(sources, &mount.use_id)?;
328 let component_path = component_dir.join("component.yml");
329 let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
330 .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
331
332 let mut steps: HashMap<String, String> = HashMap::new();
333 for step in &component.llm_steps {
334 let step_path = component_dir.join(&step.prompt_ref);
335 steps.insert(step.name.clone(), read_file(&step_path)?);
336 }
337 step_contents.insert(component.id.clone(), steps);
338 components.insert(component.id.clone(), component);
339 }
340
341 warble::compile(
342 &profile,
343 &components,
344 &binding.project,
345 context.as_ref(),
346 &step_contents,
347 )
348 .map_err(|e| e.to_string())
349}
350
351fn read_file(path: &Path) -> Result<String, String> {
352 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
353}
354
355pub fn blast_radius_for_project(
359 project_dir: &Path,
360 node: &str,
361) -> Result<warble::BlastRadius, String> {
362 blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
363}
364
365pub fn blast_radius_for_project_with(
370 project_dir: &Path,
371 node: &str,
372 resolver: &dyn ContextResolver,
373) -> Result<warble::BlastRadius, String> {
374 let profile_path = project_dir.join("profile.yml");
375 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
376 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
377
378 let binding_path = project_dir.join(&profile.context.project);
379 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
380 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
381
382 let context = resolver.resolve(&binding, project_dir)?;
383
384 if !context.is_parseable() {
385 return Err(format!(
386 "bound context '{}' is not parseable — cannot compute blast radius",
387 binding.project
388 ));
389 }
390
391 Ok(context.lineage().blast_radius(node))
392}
393
394#[cfg(test)]
395mod component_source_tests {
396 use super::{resolve_component_dir, ComponentSource};
397 use std::fs;
398 use std::path::Path;
399
400 fn stub_component(dir: &Path, id: &str) {
403 let component_dir = dir.join(id);
404 fs::create_dir_all(&component_dir).unwrap();
405 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
406 }
407
408 #[test]
409 fn resolves_from_the_only_source_that_defines_it() {
410 let hub = tempfile::tempdir().unwrap();
411 stub_component(hub.path(), "answer_query");
412
413 let sources = vec![ComponentSource::hub(hub.path())];
414 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
415 assert_eq!(resolved, hub.path().join("answer_query"));
416 }
417
418 #[test]
419 fn local_overrides_hub_when_both_define_the_same_id() {
420 let local = tempfile::tempdir().unwrap();
421 let hub = tempfile::tempdir().unwrap();
422 stub_component(local.path(), "answer_query");
423 stub_component(hub.path(), "answer_query");
424
425 let sources = vec![
426 ComponentSource::local(local.path()),
427 ComponentSource::hub(hub.path()),
428 ];
429 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
430 assert_eq!(
431 resolved,
432 local.path().join("answer_query"),
433 "a Local source must win over a Hub source defining the same id"
434 );
435 }
436
437 #[test]
438 fn falls_back_to_hub_when_local_does_not_define_the_id() {
439 let local = tempfile::tempdir().unwrap();
440 let hub = tempfile::tempdir().unwrap();
441 stub_component(hub.path(), "answer_query");
442
443 let sources = vec![
444 ComponentSource::local(local.path()),
445 ComponentSource::hub(hub.path()),
446 ];
447 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
448 assert_eq!(resolved, hub.path().join("answer_query"));
449 }
450
451 #[test]
452 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
453 let local = tempfile::tempdir().unwrap();
454 let hub = tempfile::tempdir().unwrap();
455
456 let sources = vec![
457 ComponentSource::local(local.path()),
458 ComponentSource::hub(hub.path()),
459 ];
460 let err = resolve_component_dir(&sources, "missing").unwrap_err();
461 assert!(err.contains("missing"));
462 assert!(err.contains(&local.path().display().to_string()));
463 assert!(err.contains(&hub.path().display().to_string()));
464 }
465
466 #[test]
467 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
468 let local_a = tempfile::tempdir().unwrap();
469 let local_b = tempfile::tempdir().unwrap();
470 stub_component(local_a.path(), "answer_query");
471 stub_component(local_b.path(), "answer_query");
472
473 let sources = vec![
474 ComponentSource::local(local_a.path()),
475 ComponentSource::local(local_b.path()),
476 ];
477 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
478 assert!(err.contains("ambiguous"));
479 assert!(err.contains(&local_a.path().display().to_string()));
480 assert!(err.contains(&local_b.path().display().to_string()));
481 }
482
483 #[test]
484 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
485 let local_a = tempfile::tempdir().unwrap();
488 let local_b = tempfile::tempdir().unwrap();
489 let hub = tempfile::tempdir().unwrap();
490 stub_component(local_a.path(), "answer_query");
491 stub_component(local_b.path(), "answer_query");
492 stub_component(hub.path(), "answer_query");
493
494 let sources = vec![
495 ComponentSource::local(local_a.path()),
496 ComponentSource::local(local_b.path()),
497 ComponentSource::hub(hub.path()),
498 ];
499 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
500 assert!(err.contains("ambiguous"));
501 }
502}
503
504pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
516 let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
517 format!(
518 "failed to parse IR {} for version check: {e}",
519 path.display()
520 )
521 })?;
522 match parsed.get("warble_ir_version") {
525 None => Err(format!(
526 "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
527 IR, not a hand-written subset",
528 path.display()
529 )),
530 Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
531 Some(serde_json::Value::String(v)) => Err(format!(
532 "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
533 path.display()
534 )),
535 Some(other) => Err(format!(
536 "warble_ir_version in {} is {}, not a string — eval compliance understands: \
537 {SUPPORTED_IR_VERSION}",
538 path.display(),
539 match other {
540 serde_json::Value::Null => "null",
541 serde_json::Value::Bool(_) => "a boolean",
542 serde_json::Value::Number(_) => "a number",
543 serde_json::Value::Array(_) => "an array",
544 _ => "an object",
545 }
546 )),
547 }
548}
549
550#[cfg(test)]
551mod compliance_ir_version_tests {
552 use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
553 use std::path::Path;
554
555 fn check(raw: &str) -> Result<(), String> {
556 check_compliance_ir_version(raw, Path::new("ir.json"))
557 }
558
559 #[test]
560 fn the_supported_version_passes() {
561 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
562 assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
563 }
564
565 #[test]
566 fn a_missing_field_is_reported_as_missing() {
567 let err = check(r#"{"components": []}"#).expect_err("must reject");
568 assert!(err.contains("has no warble_ir_version"), "got: {err}");
569 }
570
571 #[test]
572 fn an_out_of_range_version_is_reported_as_unsupported() {
573 let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
574 assert!(
575 err.contains("unsupported warble_ir_version '0.2'"),
576 "got: {err}"
577 );
578 assert!(
579 err.contains(SUPPORTED_IR_VERSION),
580 "must name what it does accept: {err}"
581 );
582 }
583
584 #[test]
585 fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
586 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
588 let err = check(&raw).expect_err("a superstring is a different version");
589 assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
590 }
591
592 #[test]
596 fn every_non_string_type_is_reported_as_a_type_error() {
597 for (literal, expected) in [
598 ("null", "is null"),
599 ("true", "is a boolean"),
600 ("3", "is a number"),
601 ("[]", "is an array"),
602 ("{}", "is an object"),
603 ] {
604 let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
605 let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
606 assert!(
607 err.contains(expected),
608 "for {literal} expected '{expected}', got: {err}"
609 );
610 assert!(
611 !err.contains("has no warble_ir_version"),
612 "for {literal} the field is present, not missing: {err}"
613 );
614 }
615 }
616
617 #[test]
618 fn unparseable_json_is_reported_as_a_parse_failure() {
619 let err = check("not json at all").expect_err("must reject");
620 assert!(err.contains("failed to parse IR"), "got: {err}");
621 }
622}