1pub mod gate;
23pub mod hub_fetch;
24
25use std::collections::HashMap;
26use std::fmt;
27use std::path::{Path, PathBuf};
28
29use sha2::{Digest, Sha256};
30use warble::{BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile};
31use warble_claude_code::ir::SUPPORTED_IR_VERSION;
32use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SourceKind {
42 Local,
46 Hub,
50}
51
52impl fmt::Display for SourceKind {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.write_str(match self {
55 SourceKind::Local => "local",
56 SourceKind::Hub => "hub",
57 })
58 }
59}
60
61#[derive(Debug, Clone)]
66pub struct ComponentSource {
67 pub kind: SourceKind,
68 pub components_dir: PathBuf,
69}
70
71impl ComponentSource {
72 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
73 ComponentSource {
74 kind: SourceKind::Local,
75 components_dir: components_dir.into(),
76 }
77 }
78
79 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
80 ComponentSource {
81 kind: SourceKind::Hub,
82 components_dir: components_dir.into(),
83 }
84 }
85
86 fn candidate(&self, id: &str) -> PathBuf {
87 self.components_dir.join(id)
88 }
89}
90
91fn in_repo_hub_dir() -> PathBuf {
96 Path::new(env!("CARGO_MANIFEST_DIR"))
97 .join("..")
98 .join("hub")
99 .join("components")
100}
101
102fn 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;
220
221impl ContextResolver for BuiltinContextResolver {
222 fn resolve(
223 &self,
224 binding: &BindingFile,
225 project_dir: &Path,
226 ) -> Result<Box<dyn ContextLoader>, String> {
227 if binding.kind == BindingFile::EXTERNAL {
231 return Ok(Box::new(warble::ExternalContext::new()));
232 }
233 let path = project_dir.join(&binding.project);
237 match binding.kind.as_str() {
238 BindingFile::WREN_PROJECT => {
239 if let Some(sources) = read_project_dir(&path)
240 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
241 {
242 return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
246 Ok(ctx) => ctx,
247 Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
248 }));
249 }
250 if path.join("schema.json").is_file() {
254 return Err(format!(
255 "{} holds a raw source (schema.json), not a wren project — declare \
256 `kind: {}` in the binding to bind it",
257 path.display(),
258 BindingFile::RAW_SOURCE
259 ));
260 }
261 Ok(Box::new(MdlContext::unparseable()))
264 }
265 BindingFile::RAW_SOURCE => {
266 let raw = read_raw_dir(&path)
267 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
268 .ok_or_else(|| {
269 format!(
270 "binding declares `kind: {}` but {} has no schema.json",
271 BindingFile::RAW_SOURCE,
272 path.display()
273 )
274 })?;
275 Ok(Box::new(RawSourceContext::from_sources(&raw)))
276 }
277 BindingFile::PREPARED => {
278 let document_ref = binding
283 .extra
284 .get("document")
285 .and_then(|v| v.as_str())
286 .ok_or_else(|| {
287 format!(
288 "binding declares `kind: {}` but no `document:` — add the path to the \
289 prepared-context document the host wrote, and keep `project:` as the \
290 bound layer's name",
291 BindingFile::PREPARED
292 )
293 })?;
294 let document_path = project_dir.join(document_ref);
295 let document = std::fs::read_to_string(&document_path)
299 .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
300 let context = PreparedContext::from_json(&document)
301 .map_err(|e| format!("{}: {e}", document_path.display()))?;
302 Ok(Box::new(context))
303 }
304 other => Err(format!(
305 "unknown context kind '{other}' (this build resolves '{}', '{}', '{}' and '{}'). A \
306 host that defines '{other}' must supply a ContextResolver for it — see \
307 `compile_project_to_ir_with`.",
308 BindingFile::WREN_PROJECT,
309 BindingFile::RAW_SOURCE,
310 BindingFile::EXTERNAL,
311 BindingFile::PREPARED
312 )),
313 }
314 }
315}
316
317pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
322 compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
323}
324
325pub fn compile_project_to_ir_with_sources(
332 project_dir: &Path,
333 sources: &[ComponentSource],
334) -> Result<serde_json::Value, String> {
335 compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
336}
337
338pub fn compile_project_to_ir_with(
343 project_dir: &Path,
344 sources: &[ComponentSource],
345 resolver: &dyn ContextResolver,
346) -> Result<serde_json::Value, String> {
347 let profile_path = project_dir.join("profile.yml");
348 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
349 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
350
351 let binding_path = project_dir.join(&profile.context.project);
352 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
353 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
354
355 let context = resolver.resolve(&binding, project_dir)?;
356
357 let mut components: HashMap<String, ComponentFile> = HashMap::new();
358 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
359 let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
363 for slot in &profile.slots {
364 let mut variants: HashMap<String, String> = HashMap::new();
365 for (key, reference) in &slot.variants {
366 let label = format!("profile slot '{}' variant '{}'", slot.name, key);
367 let variant_path = resolve_file_ref(project_dir, reference, &label)?;
368 variants.insert(key.clone(), read_file(&variant_path)?);
369 }
370 profile_slots.insert(slot.name.clone(), variants);
371 }
372 let mut slot_contents = warble::SlotContents {
373 profile: profile_slots,
374 ..Default::default()
375 };
376
377 for mount in &profile.components {
378 let component_dir = resolve_component_dir(sources, &mount.use_id)?;
379 let component_path = component_dir.join("component.yml");
380 let mut component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
381 .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
382
383 let mut steps: HashMap<String, String> = HashMap::new();
384 for step in &component.llm_steps {
385 let step_path = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
386 steps.insert(step.name.clone(), read_file(&step_path)?);
387 }
388 step_contents.insert(component.id.clone(), steps);
389
390 let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
394 for slot in &component.slots {
395 let mut variants: HashMap<String, String> = HashMap::new();
396 for (key, reference) in &slot.variants {
397 let label = format!("slot '{}' variant '{}'", slot.name, key);
398 let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
399 variants.insert(key.clone(), read_file(&variant_path)?);
400 }
401 slots.insert(slot.name.clone(), variants);
402 }
403 if !slots.is_empty() {
404 slot_contents.components.insert(component.id.clone(), slots);
405 }
406
407 for asset in &mut component.assets {
410 let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
411 let data = read_file_bytes(&asset_path)?;
412 asset.bytes = Some(data.len() as u64);
413 asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
414 }
415
416 components.insert(component.id.clone(), component);
417 }
418
419 warble::compile(
420 &profile,
421 &components,
422 &binding.project,
423 context.as_ref(),
424 &step_contents,
425 &slot_contents,
426 )
427 .map_err(|e| e.to_string())
428}
429
430fn read_file(path: &Path) -> Result<String, String> {
431 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
432}
433
434fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
438 std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
439}
440
441fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
466 let ref_path = Path::new(reference);
467 if ref_path.is_absolute()
468 || ref_path
469 .components()
470 .any(|c| matches!(c, std::path::Component::ParentDir))
471 {
472 return Err(format!(
473 "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
474 ));
475 }
476 let resolved = base_dir.join(ref_path);
477 if !resolved.is_file() {
478 return Err(format!(
479 "{label} '{reference}' does not exist: {}",
480 resolved.display()
481 ));
482 }
483 Ok(resolved)
484}
485
486#[cfg(test)]
487mod resolve_file_ref_tests {
488 use super::resolve_file_ref;
489
490 #[test]
491 fn accepts_a_relative_path_to_an_existing_file() {
492 let dir = tempfile::tempdir().unwrap();
493 std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
494
495 let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
496
497 assert_eq!(resolved, dir.path().join("ask.md"));
498 }
499
500 #[test]
501 fn accepts_a_relative_path_in_a_subdirectory() {
502 let dir = tempfile::tempdir().unwrap();
503 std::fs::create_dir(dir.path().join("steps")).unwrap();
504 std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
505
506 let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
507
508 assert_eq!(resolved, dir.path().join("steps/ask.md"));
509 }
510
511 #[test]
512 fn rejects_a_dotdot_escape() {
513 let dir = tempfile::tempdir().unwrap();
514
515 let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
516
517 assert!(err.contains("prompt_ref"));
518 assert!(err.contains("../secrets.md"));
519 assert!(err.contains(".."));
520 }
521
522 #[test]
523 fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
524 let dir = tempfile::tempdir().unwrap();
525
526 let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
527
528 assert!(err.contains("steps/../../escape.md"));
529 }
530
531 #[test]
532 fn rejects_an_absolute_path() {
533 let dir = tempfile::tempdir().unwrap();
534
535 let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
536
537 assert!(err.contains("prompt_ref"));
538 assert!(err.contains("/etc/passwd"));
539 }
540
541 #[test]
542 fn rejects_a_missing_file() {
543 let dir = tempfile::tempdir().unwrap();
544
545 let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
546
547 assert!(err.contains("prompt_ref"));
548 assert!(err.contains("steps/missing.md"));
549 assert!(err.contains("does not exist"));
550 }
551}
552
553pub fn blast_radius_for_project(
557 project_dir: &Path,
558 node: &str,
559) -> Result<warble::BlastRadius, String> {
560 blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
561}
562
563pub fn blast_radius_for_project_with(
568 project_dir: &Path,
569 node: &str,
570 resolver: &dyn ContextResolver,
571) -> Result<warble::BlastRadius, String> {
572 let profile_path = project_dir.join("profile.yml");
573 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
574 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
575
576 let binding_path = project_dir.join(&profile.context.project);
577 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
578 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
579
580 let context = resolver.resolve(&binding, project_dir)?;
581
582 if !context.is_parseable() {
583 return Err(format!(
584 "bound context '{}' is not parseable — cannot compute blast radius",
585 binding.project
586 ));
587 }
588
589 Ok(context.lineage().blast_radius(node))
590}
591
592#[cfg(test)]
593mod component_source_tests {
594 use super::{resolve_component_dir, ComponentSource};
595 use std::fs;
596 use std::path::Path;
597
598 fn stub_component(dir: &Path, id: &str) {
601 let component_dir = dir.join(id);
602 fs::create_dir_all(&component_dir).unwrap();
603 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
604 }
605
606 #[test]
607 fn resolves_from_the_only_source_that_defines_it() {
608 let hub = tempfile::tempdir().unwrap();
609 stub_component(hub.path(), "answer_query");
610
611 let sources = vec![ComponentSource::hub(hub.path())];
612 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
613 assert_eq!(resolved, hub.path().join("answer_query"));
614 }
615
616 #[test]
617 fn local_overrides_hub_when_both_define_the_same_id() {
618 let local = tempfile::tempdir().unwrap();
619 let hub = tempfile::tempdir().unwrap();
620 stub_component(local.path(), "answer_query");
621 stub_component(hub.path(), "answer_query");
622
623 let sources = vec![
624 ComponentSource::local(local.path()),
625 ComponentSource::hub(hub.path()),
626 ];
627 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
628 assert_eq!(
629 resolved,
630 local.path().join("answer_query"),
631 "a Local source must win over a Hub source defining the same id"
632 );
633 }
634
635 #[test]
636 fn falls_back_to_hub_when_local_does_not_define_the_id() {
637 let local = tempfile::tempdir().unwrap();
638 let hub = tempfile::tempdir().unwrap();
639 stub_component(hub.path(), "answer_query");
640
641 let sources = vec![
642 ComponentSource::local(local.path()),
643 ComponentSource::hub(hub.path()),
644 ];
645 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
646 assert_eq!(resolved, hub.path().join("answer_query"));
647 }
648
649 #[test]
650 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
651 let local = tempfile::tempdir().unwrap();
652 let hub = tempfile::tempdir().unwrap();
653
654 let sources = vec![
655 ComponentSource::local(local.path()),
656 ComponentSource::hub(hub.path()),
657 ];
658 let err = resolve_component_dir(&sources, "missing").unwrap_err();
659 assert!(err.contains("missing"));
660 assert!(err.contains(&local.path().display().to_string()));
661 assert!(err.contains(&hub.path().display().to_string()));
662 }
663
664 #[test]
665 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
666 let local_a = tempfile::tempdir().unwrap();
667 let local_b = tempfile::tempdir().unwrap();
668 stub_component(local_a.path(), "answer_query");
669 stub_component(local_b.path(), "answer_query");
670
671 let sources = vec![
672 ComponentSource::local(local_a.path()),
673 ComponentSource::local(local_b.path()),
674 ];
675 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
676 assert!(err.contains("ambiguous"));
677 assert!(err.contains(&local_a.path().display().to_string()));
678 assert!(err.contains(&local_b.path().display().to_string()));
679 }
680
681 #[test]
682 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
683 let local_a = tempfile::tempdir().unwrap();
686 let local_b = tempfile::tempdir().unwrap();
687 let hub = tempfile::tempdir().unwrap();
688 stub_component(local_a.path(), "answer_query");
689 stub_component(local_b.path(), "answer_query");
690 stub_component(hub.path(), "answer_query");
691
692 let sources = vec![
693 ComponentSource::local(local_a.path()),
694 ComponentSource::local(local_b.path()),
695 ComponentSource::hub(hub.path()),
696 ];
697 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
698 assert!(err.contains("ambiguous"));
699 }
700}
701
702pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
714 let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
715 format!(
716 "failed to parse IR {} for version check: {e}",
717 path.display()
718 )
719 })?;
720 match parsed.get("warble_ir_version") {
723 None => Err(format!(
724 "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
725 IR, not a hand-written subset",
726 path.display()
727 )),
728 Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
729 Some(serde_json::Value::String(v)) => Err(format!(
730 "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
731 path.display()
732 )),
733 Some(other) => Err(format!(
734 "warble_ir_version in {} is {}, not a string — eval compliance understands: \
735 {SUPPORTED_IR_VERSION}",
736 path.display(),
737 match other {
738 serde_json::Value::Null => "null",
739 serde_json::Value::Bool(_) => "a boolean",
740 serde_json::Value::Number(_) => "a number",
741 serde_json::Value::Array(_) => "an array",
742 _ => "an object",
743 }
744 )),
745 }
746}
747
748#[cfg(test)]
749mod compliance_ir_version_tests {
750 use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
751 use std::path::Path;
752
753 fn check(raw: &str) -> Result<(), String> {
754 check_compliance_ir_version(raw, Path::new("ir.json"))
755 }
756
757 #[test]
758 fn the_supported_version_passes() {
759 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
760 assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
761 }
762
763 #[test]
764 fn a_missing_field_is_reported_as_missing() {
765 let err = check(r#"{"components": []}"#).expect_err("must reject");
766 assert!(err.contains("has no warble_ir_version"), "got: {err}");
767 }
768
769 #[test]
770 fn an_out_of_range_version_is_reported_as_unsupported() {
771 let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
772 assert!(
773 err.contains("unsupported warble_ir_version '0.2'"),
774 "got: {err}"
775 );
776 assert!(
777 err.contains(SUPPORTED_IR_VERSION),
778 "must name what it does accept: {err}"
779 );
780 }
781
782 #[test]
783 fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
784 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
786 let err = check(&raw).expect_err("a superstring is a different version");
787 assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
788 }
789
790 #[test]
794 fn every_non_string_type_is_reported_as_a_type_error() {
795 for (literal, expected) in [
796 ("null", "is null"),
797 ("true", "is a boolean"),
798 ("3", "is a number"),
799 ("[]", "is an array"),
800 ("{}", "is an object"),
801 ] {
802 let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
803 let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
804 assert!(
805 err.contains(expected),
806 "for {literal} expected '{expected}', got: {err}"
807 );
808 assert!(
809 !err.contains("has no warble_ir_version"),
810 "for {literal} the field is present, not missing: {err}"
811 );
812 }
813 }
814
815 #[test]
816 fn unparseable_json_is_reported_as_a_parse_failure() {
817 let err = check("not json at all").expect_err("must reject");
818 assert!(err.contains("failed to parse IR"), "got: {err}");
819 }
820}