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