1pub mod gate;
23
24use std::collections::HashMap;
25use std::fmt;
26use std::path::{Path, PathBuf};
27
28use warble::{BindingFile, ComponentFile, ContextLoader, ProfileFile};
29use warble_claude_code::ir::SUPPORTED_IR_VERSION;
30use warble_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SourceKind {
40 Local,
44 Hub,
48}
49
50impl fmt::Display for SourceKind {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(match self {
53 SourceKind::Local => "local",
54 SourceKind::Hub => "hub",
55 })
56 }
57}
58
59#[derive(Debug, Clone)]
64pub struct ComponentSource {
65 pub kind: SourceKind,
66 pub components_dir: PathBuf,
67}
68
69impl ComponentSource {
70 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
71 ComponentSource {
72 kind: SourceKind::Local,
73 components_dir: components_dir.into(),
74 }
75 }
76
77 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
78 ComponentSource {
79 kind: SourceKind::Hub,
80 components_dir: components_dir.into(),
81 }
82 }
83
84 fn candidate(&self, id: &str) -> PathBuf {
85 self.components_dir.join(id)
86 }
87}
88
89fn in_repo_hub_dir() -> PathBuf {
94 Path::new(env!("CARGO_MANIFEST_DIR"))
95 .join("..")
96 .join("hub")
97 .join("components")
98}
99
100pub fn default_component_sources(project_dir: &Path) -> Vec<ComponentSource> {
106 vec![
107 ComponentSource::local(project_dir.join("components")),
108 ComponentSource::hub(in_repo_hub_dir()),
109 ]
110}
111
112fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
118 for kind in [SourceKind::Local, SourceKind::Hub] {
119 let matches: Vec<&ComponentSource> = sources
120 .iter()
121 .filter(|source| source.kind == kind)
122 .filter(|source| source.candidate(id).join("component.yml").is_file())
123 .collect();
124
125 match matches.as_slice() {
126 [] => continue,
127 [only] => return Ok(only.candidate(id)),
128 many => {
129 let dirs: Vec<String> = many
130 .iter()
131 .map(|s| s.candidate(id).display().to_string())
132 .collect();
133 return Err(format!(
134 "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
135 precedence rule distinguishes sources of the same kind",
136 many.len(),
137 dirs.join(", ")
138 ));
139 }
140 }
141 }
142
143 let searched: Vec<String> = sources
144 .iter()
145 .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
146 .collect();
147 Err(format!(
148 "component '{id}' not found in any configured source: {}",
149 searched.join("; ")
150 ))
151}
152
153pub trait ContextResolver {
164 fn resolve(
167 &self,
168 binding: &BindingFile,
169 project_dir: &Path,
170 ) -> Result<Box<dyn ContextLoader>, String>;
171}
172
173pub struct BuiltinContextResolver;
176
177impl ContextResolver for BuiltinContextResolver {
178 fn resolve(
179 &self,
180 binding: &BindingFile,
181 project_dir: &Path,
182 ) -> Result<Box<dyn ContextLoader>, String> {
183 if binding.kind == BindingFile::EXTERNAL {
187 return Ok(Box::new(warble::ExternalContext::new()));
188 }
189 let path = project_dir.join(&binding.project);
193 match binding.kind.as_str() {
194 BindingFile::WREN_PROJECT => {
195 if let Some(sources) = read_project_dir(&path)
196 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
197 {
198 return Ok(Box::new(match MdlContext::try_from_sources(&sources) {
202 Ok(ctx) => ctx,
203 Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
204 }));
205 }
206 if path.join("schema.json").is_file() {
210 return Err(format!(
211 "{} holds a raw source (schema.json), not a wren project — declare \
212 `kind: {}` in the binding to bind it",
213 path.display(),
214 BindingFile::RAW_SOURCE
215 ));
216 }
217 Ok(Box::new(MdlContext::unparseable()))
220 }
221 BindingFile::RAW_SOURCE => {
222 let raw = read_raw_dir(&path)
223 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
224 .ok_or_else(|| {
225 format!(
226 "binding declares `kind: {}` but {} has no schema.json",
227 BindingFile::RAW_SOURCE,
228 path.display()
229 )
230 })?;
231 Ok(Box::new(RawSourceContext::from_sources(&raw)))
232 }
233 other => Err(format!(
234 "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A host \
235 that defines '{other}' must supply a ContextResolver for it — see \
236 `compile_project_to_ir_with`.",
237 BindingFile::WREN_PROJECT,
238 BindingFile::RAW_SOURCE,
239 BindingFile::EXTERNAL
240 )),
241 }
242 }
243}
244
245pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
250 compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir))
251}
252
253pub fn compile_project_to_ir_with_sources(
260 project_dir: &Path,
261 sources: &[ComponentSource],
262) -> Result<serde_json::Value, String> {
263 compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
264}
265
266pub fn compile_project_to_ir_with(
271 project_dir: &Path,
272 sources: &[ComponentSource],
273 resolver: &dyn ContextResolver,
274) -> Result<serde_json::Value, String> {
275 let profile_path = project_dir.join("profile.yml");
276 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
277 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
278
279 let binding_path = project_dir.join(&profile.context.project);
280 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
281 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
282
283 let context = resolver.resolve(&binding, project_dir)?;
284
285 let mut components: HashMap<String, ComponentFile> = HashMap::new();
286 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
287
288 for mount in &profile.components {
289 let component_dir = resolve_component_dir(sources, &mount.use_id)?;
290 let component_path = component_dir.join("component.yml");
291 let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
292 .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
293
294 let mut steps: HashMap<String, String> = HashMap::new();
295 for step in &component.llm_steps {
296 let step_path = component_dir.join(&step.prompt_ref);
297 steps.insert(step.name.clone(), read_file(&step_path)?);
298 }
299 step_contents.insert(component.id.clone(), steps);
300 components.insert(component.id.clone(), component);
301 }
302
303 warble::compile(
304 &profile,
305 &components,
306 &binding.project,
307 context.as_ref(),
308 &step_contents,
309 )
310 .map_err(|e| e.to_string())
311}
312
313fn read_file(path: &Path) -> Result<String, String> {
314 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
315}
316
317pub fn blast_radius_for_project(
321 project_dir: &Path,
322 node: &str,
323) -> Result<warble::BlastRadius, String> {
324 blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
325}
326
327pub fn blast_radius_for_project_with(
332 project_dir: &Path,
333 node: &str,
334 resolver: &dyn ContextResolver,
335) -> Result<warble::BlastRadius, String> {
336 let profile_path = project_dir.join("profile.yml");
337 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
338 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
339
340 let binding_path = project_dir.join(&profile.context.project);
341 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
342 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
343
344 let context = resolver.resolve(&binding, project_dir)?;
345
346 if !context.is_parseable() {
347 return Err(format!(
348 "bound context '{}' is not parseable — cannot compute blast radius",
349 binding.project
350 ));
351 }
352
353 Ok(context.lineage().blast_radius(node))
354}
355
356#[cfg(test)]
357mod component_source_tests {
358 use super::{resolve_component_dir, ComponentSource};
359 use std::fs;
360 use std::path::Path;
361
362 fn stub_component(dir: &Path, id: &str) {
365 let component_dir = dir.join(id);
366 fs::create_dir_all(&component_dir).unwrap();
367 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
368 }
369
370 #[test]
371 fn resolves_from_the_only_source_that_defines_it() {
372 let hub = tempfile::tempdir().unwrap();
373 stub_component(hub.path(), "answer_query");
374
375 let sources = vec![ComponentSource::hub(hub.path())];
376 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
377 assert_eq!(resolved, hub.path().join("answer_query"));
378 }
379
380 #[test]
381 fn local_overrides_hub_when_both_define_the_same_id() {
382 let local = tempfile::tempdir().unwrap();
383 let hub = tempfile::tempdir().unwrap();
384 stub_component(local.path(), "answer_query");
385 stub_component(hub.path(), "answer_query");
386
387 let sources = vec![
388 ComponentSource::local(local.path()),
389 ComponentSource::hub(hub.path()),
390 ];
391 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
392 assert_eq!(
393 resolved,
394 local.path().join("answer_query"),
395 "a Local source must win over a Hub source defining the same id"
396 );
397 }
398
399 #[test]
400 fn falls_back_to_hub_when_local_does_not_define_the_id() {
401 let local = tempfile::tempdir().unwrap();
402 let hub = tempfile::tempdir().unwrap();
403 stub_component(hub.path(), "answer_query");
404
405 let sources = vec![
406 ComponentSource::local(local.path()),
407 ComponentSource::hub(hub.path()),
408 ];
409 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
410 assert_eq!(resolved, hub.path().join("answer_query"));
411 }
412
413 #[test]
414 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
415 let local = tempfile::tempdir().unwrap();
416 let hub = tempfile::tempdir().unwrap();
417
418 let sources = vec![
419 ComponentSource::local(local.path()),
420 ComponentSource::hub(hub.path()),
421 ];
422 let err = resolve_component_dir(&sources, "missing").unwrap_err();
423 assert!(err.contains("missing"));
424 assert!(err.contains(&local.path().display().to_string()));
425 assert!(err.contains(&hub.path().display().to_string()));
426 }
427
428 #[test]
429 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
430 let local_a = tempfile::tempdir().unwrap();
431 let local_b = tempfile::tempdir().unwrap();
432 stub_component(local_a.path(), "answer_query");
433 stub_component(local_b.path(), "answer_query");
434
435 let sources = vec![
436 ComponentSource::local(local_a.path()),
437 ComponentSource::local(local_b.path()),
438 ];
439 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
440 assert!(err.contains("ambiguous"));
441 assert!(err.contains(&local_a.path().display().to_string()));
442 assert!(err.contains(&local_b.path().display().to_string()));
443 }
444
445 #[test]
446 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
447 let local_a = tempfile::tempdir().unwrap();
450 let local_b = tempfile::tempdir().unwrap();
451 let hub = tempfile::tempdir().unwrap();
452 stub_component(local_a.path(), "answer_query");
453 stub_component(local_b.path(), "answer_query");
454 stub_component(hub.path(), "answer_query");
455
456 let sources = vec![
457 ComponentSource::local(local_a.path()),
458 ComponentSource::local(local_b.path()),
459 ComponentSource::hub(hub.path()),
460 ];
461 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
462 assert!(err.contains("ambiguous"));
463 }
464}
465
466pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
478 let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
479 format!(
480 "failed to parse IR {} for version check: {e}",
481 path.display()
482 )
483 })?;
484 match parsed.get("warble_ir_version") {
487 None => Err(format!(
488 "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
489 IR, not a hand-written subset",
490 path.display()
491 )),
492 Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
493 Some(serde_json::Value::String(v)) => Err(format!(
494 "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
495 path.display()
496 )),
497 Some(other) => Err(format!(
498 "warble_ir_version in {} is {}, not a string — eval compliance understands: \
499 {SUPPORTED_IR_VERSION}",
500 path.display(),
501 match other {
502 serde_json::Value::Null => "null",
503 serde_json::Value::Bool(_) => "a boolean",
504 serde_json::Value::Number(_) => "a number",
505 serde_json::Value::Array(_) => "an array",
506 _ => "an object",
507 }
508 )),
509 }
510}
511
512#[cfg(test)]
513mod compliance_ir_version_tests {
514 use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
515 use std::path::Path;
516
517 fn check(raw: &str) -> Result<(), String> {
518 check_compliance_ir_version(raw, Path::new("ir.json"))
519 }
520
521 #[test]
522 fn the_supported_version_passes() {
523 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
524 assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
525 }
526
527 #[test]
528 fn a_missing_field_is_reported_as_missing() {
529 let err = check(r#"{"components": []}"#).expect_err("must reject");
530 assert!(err.contains("has no warble_ir_version"), "got: {err}");
531 }
532
533 #[test]
534 fn an_out_of_range_version_is_reported_as_unsupported() {
535 let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
536 assert!(
537 err.contains("unsupported warble_ir_version '0.2'"),
538 "got: {err}"
539 );
540 assert!(
541 err.contains(SUPPORTED_IR_VERSION),
542 "must name what it does accept: {err}"
543 );
544 }
545
546 #[test]
547 fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
548 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
550 let err = check(&raw).expect_err("a superstring is a different version");
551 assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
552 }
553
554 #[test]
558 fn every_non_string_type_is_reported_as_a_type_error() {
559 for (literal, expected) in [
560 ("null", "is null"),
561 ("true", "is a boolean"),
562 ("3", "is a number"),
563 ("[]", "is an array"),
564 ("{}", "is an object"),
565 ] {
566 let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
567 let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
568 assert!(
569 err.contains(expected),
570 "for {literal} expected '{expected}', got: {err}"
571 );
572 assert!(
573 !err.contains("has no warble_ir_version"),
574 "for {literal} the field is present, not missing: {err}"
575 );
576 }
577 }
578
579 #[test]
580 fn unparseable_json_is_reported_as_a_parse_failure() {
581 let err = check("not json at all").expect_err("must reject");
582 assert!(err.contains("failed to parse IR"), "got: {err}");
583 }
584}