1pub mod gate;
23pub mod hub_fetch;
24pub mod overlay;
25
26use std::collections::HashMap;
27use std::fmt;
28use std::path::{Path, PathBuf};
29
30use sha2::{Digest, Sha256};
31use warble::{
32 read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile,
33 RawSourceContext,
34};
35use warble_claude_code::ir::SUPPORTED_IR_VERSION;
36use warble_mdl_context::{read_project_dir, MdlContext};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum SourceKind {
46 Local,
50 Hub,
54}
55
56impl fmt::Display for SourceKind {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(match self {
59 SourceKind::Local => "local",
60 SourceKind::Hub => "hub",
61 })
62 }
63}
64
65#[derive(Debug, Clone)]
70pub struct ComponentSource {
71 pub kind: SourceKind,
72 pub components_dir: PathBuf,
73}
74
75impl ComponentSource {
76 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
77 ComponentSource {
78 kind: SourceKind::Local,
79 components_dir: components_dir.into(),
80 }
81 }
82
83 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
84 ComponentSource {
85 kind: SourceKind::Hub,
86 components_dir: components_dir.into(),
87 }
88 }
89
90 fn candidate(&self, id: &str) -> PathBuf {
91 self.components_dir.join(id)
92 }
93}
94
95fn in_repo_hub_dir() -> PathBuf {
100 Path::new(env!("CARGO_MANIFEST_DIR"))
101 .join("..")
102 .join("hub")
103 .join("components")
104}
105
106fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
117 let in_repo = in_repo_hub_dir();
118 if in_repo.is_dir() {
119 return Ok(ComponentSource::hub(in_repo));
120 }
121 let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
122 let cached_dir = hub_fetch::ensure_cached_hub(version)?;
123 Ok(ComponentSource::hub(cached_dir))
124}
125
126pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
137 default_component_sources_with_hub_version(project_dir, None)
138}
139
140pub fn default_component_sources_with_hub_version(
145 project_dir: &Path,
146 hub_version: Option<&str>,
147) -> Result<Vec<ComponentSource>, String> {
148 Ok(vec![
149 ComponentSource::local(project_dir.join("components")),
150 default_hub_source(hub_version)?,
151 ])
152}
153
154fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
160 for kind in [SourceKind::Local, SourceKind::Hub] {
161 let matches: Vec<&ComponentSource> = sources
162 .iter()
163 .filter(|source| source.kind == kind)
164 .filter(|source| source.candidate(id).join("component.yml").is_file())
165 .collect();
166
167 match matches.as_slice() {
168 [] => continue,
169 [only] => return Ok(only.candidate(id)),
170 many => {
171 let dirs: Vec<String> = many
172 .iter()
173 .map(|s| s.candidate(id).display().to_string())
174 .collect();
175 return Err(format!(
176 "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
177 precedence rule distinguishes sources of the same kind",
178 many.len(),
179 dirs.join(", ")
180 ));
181 }
182 }
183 }
184
185 let searched: Vec<String> = sources
186 .iter()
187 .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
188 .collect();
189 Err(format!(
190 "component '{id}' not found in any configured source: {}",
191 searched.join("; ")
192 ))
193}
194
195pub trait ContextResolver {
206 fn resolve(
209 &self,
210 binding: &BindingFile,
211 project_dir: &Path,
212 ) -> Result<Box<dyn ContextLoader>, String>;
213}
214
215pub struct BuiltinContextResolver;
224
225impl ContextResolver for BuiltinContextResolver {
226 fn resolve(
227 &self,
228 binding: &BindingFile,
229 project_dir: &Path,
230 ) -> Result<Box<dyn ContextLoader>, String> {
231 if binding.kind == BindingFile::EXTERNAL {
235 return Ok(Box::new(warble::ExternalContext::new()));
236 }
237 let path = project_dir.join(&binding.project);
241 match binding.kind.as_str() {
242 BindingFile::WREN_PROJECT => {
243 if let Some(sources) = read_project_dir(&path)
244 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
245 {
246 return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
250 Ok(ctx) => ctx,
251 Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
252 }));
253 }
254 if path.join("schema.json").is_file() {
258 return Err(format!(
259 "{} holds a raw source (schema.json), not a wren project — declare \
260 `kind: {}` in the binding to bind it",
261 path.display(),
262 BindingFile::RAW_SOURCE
263 ));
264 }
265 Ok(Box::new(MdlContext::unparseable()))
268 }
269 BindingFile::RAW_SOURCE => {
270 let raw = read_raw_dir(&path)
271 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
272 .ok_or_else(|| {
273 format!(
274 "binding declares `kind: {}` but {} has no schema.json",
275 BindingFile::RAW_SOURCE,
276 path.display()
277 )
278 })?;
279 Ok(Box::new(RawSourceContext::from_sources(&raw)))
280 }
281 BindingFile::PREPARED => {
282 let document_ref = binding
287 .extra
288 .get("document")
289 .and_then(|v| v.as_str())
290 .ok_or_else(|| {
291 format!(
292 "binding declares `kind: {}` but no `document:` — add the path to the \
293 prepared-context document the host wrote, and keep `project:` as the \
294 bound layer's name",
295 BindingFile::PREPARED
296 )
297 })?;
298 let document_path = project_dir.join(document_ref);
299 let document = std::fs::read_to_string(&document_path)
303 .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
304 let context = PreparedContext::from_json(&document)
305 .map_err(|e| format!("{}: {e}", document_path.display()))?;
306 Ok(Box::new(context))
307 }
308 other => Err(format!(
309 "unknown context kind '{other}' (this build resolves '{}', '{}', '{}' and '{}'). A \
310 host that defines '{other}' must supply a ContextResolver for it — see \
311 `compile_project_to_ir_with`.",
312 BindingFile::WREN_PROJECT,
313 BindingFile::RAW_SOURCE,
314 BindingFile::EXTERNAL,
315 BindingFile::PREPARED
316 )),
317 }
318 }
319}
320
321pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
326 compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
327}
328
329pub fn compile_project_to_ir_with_sources(
336 project_dir: &Path,
337 sources: &[ComponentSource],
338) -> Result<serde_json::Value, String> {
339 compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
340}
341
342pub fn compile_project_to_ir_with(
347 project_dir: &Path,
348 sources: &[ComponentSource],
349 resolver: &dyn ContextResolver,
350) -> Result<serde_json::Value, String> {
351 compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
352}
353
354pub fn compile_project_to_ir_with_overlay(
367 project_dir: &Path,
368 sources: &[ComponentSource],
369 resolver: &dyn ContextResolver,
370 overlay_path: Option<&Path>,
371) -> Result<serde_json::Value, String> {
372 let profile_path = project_dir.join("profile.yml");
373 let mut profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
374 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
375
376 if let Some(path) = overlay_path {
377 let overlay = overlay::read_overlay(path)?;
378 overlay::apply_overlay(&mut profile, &overlay)?;
379 }
380
381 let binding_path = project_dir.join(&profile.context.project);
382 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
383 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
384
385 let context = resolver.resolve(&binding, project_dir)?;
386
387 let mut components: HashMap<String, ComponentFile> = HashMap::new();
388 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
389 let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
393 for slot in &profile.slots {
394 let mut variants: HashMap<String, String> = HashMap::new();
395 for (key, reference) in &slot.variants {
396 let label = format!("profile slot '{}' variant '{}'", slot.name, key);
397 let variant_path = resolve_file_ref(project_dir, reference, &label)?;
398 variants.insert(key.clone(), read_file(&variant_path)?);
399 }
400 profile_slots.insert(slot.name.clone(), variants);
401 }
402 let mut slot_contents = warble::SlotContents {
403 profile: profile_slots,
404 ..Default::default()
405 };
406
407 for mount in &profile.components {
408 let component_dir = resolve_component_dir(sources, &mount.use_id)?;
409 let component_path = component_dir.join("component.yml");
410 let mut component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
411 .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
412
413 let mut steps: HashMap<String, String> = HashMap::new();
414 for step in &component.llm_steps {
415 let step_path = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
416 steps.insert(step.name.clone(), read_file(&step_path)?);
417 }
418 step_contents.insert(component.id.clone(), steps);
419
420 let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
424 for slot in &component.slots {
425 let mut variants: HashMap<String, String> = HashMap::new();
426 for (key, reference) in &slot.variants {
427 let label = format!("slot '{}' variant '{}'", slot.name, key);
428 let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
429 variants.insert(key.clone(), read_file(&variant_path)?);
430 }
431 slots.insert(slot.name.clone(), variants);
432 }
433 if !slots.is_empty() {
434 slot_contents.components.insert(component.id.clone(), slots);
435 }
436
437 for asset in &mut component.assets {
440 let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
441 let data = read_file_bytes(&asset_path)?;
442 asset.bytes = Some(data.len() as u64);
443 asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
444 }
445
446 components.insert(component.id.clone(), component);
447 }
448
449 warble::compile(
450 &profile,
451 &components,
452 &binding.project,
453 context.as_ref(),
454 &step_contents,
455 &slot_contents,
456 )
457 .map_err(|e| e.to_string())
458}
459
460fn read_file(path: &Path) -> Result<String, String> {
461 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
462}
463
464fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
468 std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
469}
470
471fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
496 let ref_path = Path::new(reference);
497 if ref_path.is_absolute()
498 || ref_path
499 .components()
500 .any(|c| matches!(c, std::path::Component::ParentDir))
501 {
502 return Err(format!(
503 "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
504 ));
505 }
506 let resolved = base_dir.join(ref_path);
507 if !resolved.is_file() {
508 return Err(format!(
509 "{label} '{reference}' does not exist: {}",
510 resolved.display()
511 ));
512 }
513 Ok(resolved)
514}
515
516#[cfg(test)]
517mod resolve_file_ref_tests {
518 use super::resolve_file_ref;
519
520 #[test]
521 fn accepts_a_relative_path_to_an_existing_file() {
522 let dir = tempfile::tempdir().unwrap();
523 std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
524
525 let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
526
527 assert_eq!(resolved, dir.path().join("ask.md"));
528 }
529
530 #[test]
531 fn accepts_a_relative_path_in_a_subdirectory() {
532 let dir = tempfile::tempdir().unwrap();
533 std::fs::create_dir(dir.path().join("steps")).unwrap();
534 std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
535
536 let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
537
538 assert_eq!(resolved, dir.path().join("steps/ask.md"));
539 }
540
541 #[test]
542 fn rejects_a_dotdot_escape() {
543 let dir = tempfile::tempdir().unwrap();
544
545 let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
546
547 assert!(err.contains("prompt_ref"));
548 assert!(err.contains("../secrets.md"));
549 assert!(err.contains(".."));
550 }
551
552 #[test]
553 fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
554 let dir = tempfile::tempdir().unwrap();
555
556 let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
557
558 assert!(err.contains("steps/../../escape.md"));
559 }
560
561 #[test]
562 fn rejects_an_absolute_path() {
563 let dir = tempfile::tempdir().unwrap();
564
565 let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
566
567 assert!(err.contains("prompt_ref"));
568 assert!(err.contains("/etc/passwd"));
569 }
570
571 #[test]
572 fn rejects_a_missing_file() {
573 let dir = tempfile::tempdir().unwrap();
574
575 let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
576
577 assert!(err.contains("prompt_ref"));
578 assert!(err.contains("steps/missing.md"));
579 assert!(err.contains("does not exist"));
580 }
581}
582
583pub fn blast_radius_for_project(
587 project_dir: &Path,
588 node: &str,
589) -> Result<warble::BlastRadius, String> {
590 blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
591}
592
593pub fn blast_radius_for_project_with(
598 project_dir: &Path,
599 node: &str,
600 resolver: &dyn ContextResolver,
601) -> Result<warble::BlastRadius, String> {
602 let profile_path = project_dir.join("profile.yml");
603 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
604 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
605
606 let binding_path = project_dir.join(&profile.context.project);
607 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
608 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
609
610 let context = resolver.resolve(&binding, project_dir)?;
611
612 if !context.is_parseable() {
613 return Err(format!(
614 "bound context '{}' is not parseable — cannot compute blast radius",
615 binding.project
616 ));
617 }
618
619 Ok(context.lineage().blast_radius(node))
620}
621
622#[cfg(test)]
623mod component_source_tests {
624 use super::{resolve_component_dir, ComponentSource};
625 use std::fs;
626 use std::path::Path;
627
628 fn stub_component(dir: &Path, id: &str) {
631 let component_dir = dir.join(id);
632 fs::create_dir_all(&component_dir).unwrap();
633 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
634 }
635
636 #[test]
637 fn resolves_from_the_only_source_that_defines_it() {
638 let hub = tempfile::tempdir().unwrap();
639 stub_component(hub.path(), "answer_query");
640
641 let sources = vec![ComponentSource::hub(hub.path())];
642 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
643 assert_eq!(resolved, hub.path().join("answer_query"));
644 }
645
646 #[test]
647 fn local_overrides_hub_when_both_define_the_same_id() {
648 let local = tempfile::tempdir().unwrap();
649 let hub = tempfile::tempdir().unwrap();
650 stub_component(local.path(), "answer_query");
651 stub_component(hub.path(), "answer_query");
652
653 let sources = vec![
654 ComponentSource::local(local.path()),
655 ComponentSource::hub(hub.path()),
656 ];
657 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
658 assert_eq!(
659 resolved,
660 local.path().join("answer_query"),
661 "a Local source must win over a Hub source defining the same id"
662 );
663 }
664
665 #[test]
666 fn falls_back_to_hub_when_local_does_not_define_the_id() {
667 let local = tempfile::tempdir().unwrap();
668 let hub = tempfile::tempdir().unwrap();
669 stub_component(hub.path(), "answer_query");
670
671 let sources = vec![
672 ComponentSource::local(local.path()),
673 ComponentSource::hub(hub.path()),
674 ];
675 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
676 assert_eq!(resolved, hub.path().join("answer_query"));
677 }
678
679 #[test]
680 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
681 let local = tempfile::tempdir().unwrap();
682 let hub = tempfile::tempdir().unwrap();
683
684 let sources = vec![
685 ComponentSource::local(local.path()),
686 ComponentSource::hub(hub.path()),
687 ];
688 let err = resolve_component_dir(&sources, "missing").unwrap_err();
689 assert!(err.contains("missing"));
690 assert!(err.contains(&local.path().display().to_string()));
691 assert!(err.contains(&hub.path().display().to_string()));
692 }
693
694 #[test]
695 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
696 let local_a = tempfile::tempdir().unwrap();
697 let local_b = tempfile::tempdir().unwrap();
698 stub_component(local_a.path(), "answer_query");
699 stub_component(local_b.path(), "answer_query");
700
701 let sources = vec![
702 ComponentSource::local(local_a.path()),
703 ComponentSource::local(local_b.path()),
704 ];
705 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
706 assert!(err.contains("ambiguous"));
707 assert!(err.contains(&local_a.path().display().to_string()));
708 assert!(err.contains(&local_b.path().display().to_string()));
709 }
710
711 #[test]
712 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
713 let local_a = tempfile::tempdir().unwrap();
716 let local_b = tempfile::tempdir().unwrap();
717 let hub = tempfile::tempdir().unwrap();
718 stub_component(local_a.path(), "answer_query");
719 stub_component(local_b.path(), "answer_query");
720 stub_component(hub.path(), "answer_query");
721
722 let sources = vec![
723 ComponentSource::local(local_a.path()),
724 ComponentSource::local(local_b.path()),
725 ComponentSource::hub(hub.path()),
726 ];
727 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
728 assert!(err.contains("ambiguous"));
729 }
730}
731
732pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
744 let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
745 format!(
746 "failed to parse IR {} for version check: {e}",
747 path.display()
748 )
749 })?;
750 match parsed.get("warble_ir_version") {
753 None => Err(format!(
754 "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
755 IR, not a hand-written subset",
756 path.display()
757 )),
758 Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
759 Some(serde_json::Value::String(v)) => Err(format!(
760 "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
761 path.display()
762 )),
763 Some(other) => Err(format!(
764 "warble_ir_version in {} is {}, not a string — eval compliance understands: \
765 {SUPPORTED_IR_VERSION}",
766 path.display(),
767 match other {
768 serde_json::Value::Null => "null",
769 serde_json::Value::Bool(_) => "a boolean",
770 serde_json::Value::Number(_) => "a number",
771 serde_json::Value::Array(_) => "an array",
772 _ => "an object",
773 }
774 )),
775 }
776}
777
778#[cfg(test)]
779mod compliance_ir_version_tests {
780 use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
781 use std::path::Path;
782
783 fn check(raw: &str) -> Result<(), String> {
784 check_compliance_ir_version(raw, Path::new("ir.json"))
785 }
786
787 #[test]
788 fn the_supported_version_passes() {
789 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
790 assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
791 }
792
793 #[test]
794 fn a_missing_field_is_reported_as_missing() {
795 let err = check(r#"{"components": []}"#).expect_err("must reject");
796 assert!(err.contains("has no warble_ir_version"), "got: {err}");
797 }
798
799 #[test]
800 fn an_out_of_range_version_is_reported_as_unsupported() {
801 let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
802 assert!(
803 err.contains("unsupported warble_ir_version '0.2'"),
804 "got: {err}"
805 );
806 assert!(
807 err.contains(SUPPORTED_IR_VERSION),
808 "must name what it does accept: {err}"
809 );
810 }
811
812 #[test]
813 fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
814 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
816 let err = check(&raw).expect_err("a superstring is a different version");
817 assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
818 }
819
820 #[test]
824 fn every_non_string_type_is_reported_as_a_type_error() {
825 for (literal, expected) in [
826 ("null", "is null"),
827 ("true", "is a boolean"),
828 ("3", "is a number"),
829 ("[]", "is an array"),
830 ("{}", "is an object"),
831 ] {
832 let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
833 let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
834 assert!(
835 err.contains(expected),
836 "for {literal} expected '{expected}', got: {err}"
837 );
838 assert!(
839 !err.contains("has no warble_ir_version"),
840 "for {literal} the field is present, not missing: {err}"
841 );
842 }
843 }
844
845 #[test]
846 fn unparseable_json_is_reported_as_a_parse_failure() {
847 let err = check("not json at all").expect_err("must reject");
848 assert!(err.contains("failed to parse IR"), "got: {err}");
849 }
850}