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_mdl_context::{read_project_dir, read_raw_dir, MdlContext, RawSourceContext};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SourceKind {
39 Local,
43 Hub,
47}
48
49impl fmt::Display for SourceKind {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.write_str(match self {
52 SourceKind::Local => "local",
53 SourceKind::Hub => "hub",
54 })
55 }
56}
57
58#[derive(Debug, Clone)]
63pub struct ComponentSource {
64 pub kind: SourceKind,
65 pub components_dir: PathBuf,
66}
67
68impl ComponentSource {
69 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
70 ComponentSource {
71 kind: SourceKind::Local,
72 components_dir: components_dir.into(),
73 }
74 }
75
76 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
77 ComponentSource {
78 kind: SourceKind::Hub,
79 components_dir: components_dir.into(),
80 }
81 }
82
83 fn candidate(&self, id: &str) -> PathBuf {
84 self.components_dir.join(id)
85 }
86}
87
88fn in_repo_hub_dir() -> PathBuf {
93 Path::new(env!("CARGO_MANIFEST_DIR"))
94 .join("..")
95 .join("hub")
96 .join("components")
97}
98
99pub fn default_component_sources(project_dir: &Path) -> Vec<ComponentSource> {
105 vec![
106 ComponentSource::local(project_dir.join("components")),
107 ComponentSource::hub(in_repo_hub_dir()),
108 ]
109}
110
111fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
117 for kind in [SourceKind::Local, SourceKind::Hub] {
118 let matches: Vec<&ComponentSource> = sources
119 .iter()
120 .filter(|source| source.kind == kind)
121 .filter(|source| source.candidate(id).join("component.yml").is_file())
122 .collect();
123
124 match matches.as_slice() {
125 [] => continue,
126 [only] => return Ok(only.candidate(id)),
127 many => {
128 let dirs: Vec<String> = many
129 .iter()
130 .map(|s| s.candidate(id).display().to_string())
131 .collect();
132 return Err(format!(
133 "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
134 precedence rule distinguishes sources of the same kind",
135 many.len(),
136 dirs.join(", ")
137 ));
138 }
139 }
140 }
141
142 let searched: Vec<String> = sources
143 .iter()
144 .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
145 .collect();
146 Err(format!(
147 "component '{id}' not found in any configured source: {}",
148 searched.join("; ")
149 ))
150}
151
152fn resolve_context(resolved_project_path: &Path) -> Result<Box<dyn ContextLoader>, String> {
156 if let Some(sources) = read_project_dir(resolved_project_path)
157 .map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
158 {
159 let context = match MdlContext::try_from_sources(&sources) {
163 Ok(ctx) => ctx,
164 Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
165 };
166 return Ok(Box::new(context));
167 }
168 if let Some(raw) = read_raw_dir(resolved_project_path)
169 .map_err(|e| format!("failed to read {}: {e}", resolved_project_path.display()))?
170 {
171 return Ok(Box::new(RawSourceContext::from_sources(&raw)));
172 }
173 Ok(Box::new(MdlContext::unparseable()))
174}
175
176pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
181 compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir))
182}
183
184pub fn compile_project_to_ir_with_sources(
191 project_dir: &Path,
192 sources: &[ComponentSource],
193) -> Result<serde_json::Value, String> {
194 let profile_path = project_dir.join("profile.yml");
195 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
196 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
197
198 let binding_path = project_dir.join(&profile.context.project);
199 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
200 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
201
202 let resolved_project_path = project_dir.join(&binding.project);
206 let context = resolve_context(&resolved_project_path)?;
207
208 let mut components: HashMap<String, ComponentFile> = HashMap::new();
209 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
210
211 for mount in &profile.components {
212 let component_dir = resolve_component_dir(sources, &mount.use_id)?;
213 let component_path = component_dir.join("component.yml");
214 let component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
215 .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
216
217 let mut steps: HashMap<String, String> = HashMap::new();
218 for step in &component.llm_steps {
219 let step_path = component_dir.join(&step.prompt_ref);
220 steps.insert(step.name.clone(), read_file(&step_path)?);
221 }
222 step_contents.insert(component.id.clone(), steps);
223 components.insert(component.id.clone(), component);
224 }
225
226 warble::compile(
227 &profile,
228 &components,
229 &binding.project,
230 context.as_ref(),
231 &step_contents,
232 )
233 .map_err(|e| e.to_string())
234}
235
236fn read_file(path: &Path) -> Result<String, String> {
237 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
238}
239
240pub fn blast_radius_for_project(
244 project_dir: &Path,
245 node: &str,
246) -> Result<warble::BlastRadius, String> {
247 let profile_path = project_dir.join("profile.yml");
248 let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
249 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
250
251 let binding_path = project_dir.join(&profile.context.project);
252 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
253 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
254
255 let resolved_project_path = project_dir.join(&binding.project);
256 let context = resolve_context(&resolved_project_path)?;
257
258 if !context.is_parseable() {
259 return Err(format!(
260 "wren project at {} is not parseable — cannot compute blast radius",
261 resolved_project_path.display()
262 ));
263 }
264
265 Ok(context.lineage().blast_radius(node))
266}
267
268#[cfg(test)]
269mod component_source_tests {
270 use super::{resolve_component_dir, ComponentSource};
271 use std::fs;
272 use std::path::Path;
273
274 fn stub_component(dir: &Path, id: &str) {
277 let component_dir = dir.join(id);
278 fs::create_dir_all(&component_dir).unwrap();
279 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
280 }
281
282 #[test]
283 fn resolves_from_the_only_source_that_defines_it() {
284 let hub = tempfile::tempdir().unwrap();
285 stub_component(hub.path(), "answer_query");
286
287 let sources = vec![ComponentSource::hub(hub.path())];
288 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
289 assert_eq!(resolved, hub.path().join("answer_query"));
290 }
291
292 #[test]
293 fn local_overrides_hub_when_both_define_the_same_id() {
294 let local = tempfile::tempdir().unwrap();
295 let hub = tempfile::tempdir().unwrap();
296 stub_component(local.path(), "answer_query");
297 stub_component(hub.path(), "answer_query");
298
299 let sources = vec![
300 ComponentSource::local(local.path()),
301 ComponentSource::hub(hub.path()),
302 ];
303 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
304 assert_eq!(
305 resolved,
306 local.path().join("answer_query"),
307 "a Local source must win over a Hub source defining the same id"
308 );
309 }
310
311 #[test]
312 fn falls_back_to_hub_when_local_does_not_define_the_id() {
313 let local = tempfile::tempdir().unwrap();
314 let hub = tempfile::tempdir().unwrap();
315 stub_component(hub.path(), "answer_query");
316
317 let sources = vec![
318 ComponentSource::local(local.path()),
319 ComponentSource::hub(hub.path()),
320 ];
321 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
322 assert_eq!(resolved, hub.path().join("answer_query"));
323 }
324
325 #[test]
326 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
327 let local = tempfile::tempdir().unwrap();
328 let hub = tempfile::tempdir().unwrap();
329
330 let sources = vec![
331 ComponentSource::local(local.path()),
332 ComponentSource::hub(hub.path()),
333 ];
334 let err = resolve_component_dir(&sources, "missing").unwrap_err();
335 assert!(err.contains("missing"));
336 assert!(err.contains(&local.path().display().to_string()));
337 assert!(err.contains(&hub.path().display().to_string()));
338 }
339
340 #[test]
341 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
342 let local_a = tempfile::tempdir().unwrap();
343 let local_b = tempfile::tempdir().unwrap();
344 stub_component(local_a.path(), "answer_query");
345 stub_component(local_b.path(), "answer_query");
346
347 let sources = vec![
348 ComponentSource::local(local_a.path()),
349 ComponentSource::local(local_b.path()),
350 ];
351 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
352 assert!(err.contains("ambiguous"));
353 assert!(err.contains(&local_a.path().display().to_string()));
354 assert!(err.contains(&local_b.path().display().to_string()));
355 }
356
357 #[test]
358 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
359 let local_a = tempfile::tempdir().unwrap();
362 let local_b = tempfile::tempdir().unwrap();
363 let hub = tempfile::tempdir().unwrap();
364 stub_component(local_a.path(), "answer_query");
365 stub_component(local_b.path(), "answer_query");
366 stub_component(hub.path(), "answer_query");
367
368 let sources = vec![
369 ComponentSource::local(local_a.path()),
370 ComponentSource::local(local_b.path()),
371 ComponentSource::hub(hub.path()),
372 ];
373 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
374 assert!(err.contains("ambiguous"));
375 }
376}