1use std::collections::{BTreeMap, HashMap, HashSet};
17
18use crate::error::DispatchError;
19use crate::ir::SlotDecl;
20
21pub type SlotSupply = BTreeMap<String, Option<String>>;
30
31fn is_slot_name(name: &str) -> bool {
34 let mut chars = name.chars();
35 match chars.next() {
36 Some(c) if c.is_ascii_lowercase() || c == '_' => {}
37 _ => return false,
38 }
39 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
40}
41
42fn slot_references(raw: &str) -> Vec<(usize, usize, String)> {
49 let mut found = Vec::new();
50 let mut cursor = 0usize;
51 while let Some(open) = raw[cursor..].find("{{") {
52 let start = cursor + open;
53 let body_start = start + 2;
54 let Some(close) = raw[body_start..].find("}}") else {
55 return found;
56 };
57 let end = body_start + close + 2;
58 let body = raw[body_start..body_start + close].trim();
59 if let Some(name) = body.strip_prefix("slot.") {
60 if is_slot_name(name) {
61 found.push((start, end, name.to_string()));
62 }
63 }
64 cursor = end;
65 }
66 found
67}
68
69pub fn resolve_slots(
81 decls: &[SlotDecl],
82 supply: &SlotSupply,
83 scope: &str,
84) -> Result<HashMap<String, Option<String>>, DispatchError> {
85 let by_name: HashMap<&str, &SlotDecl> = decls.iter().map(|d| (d.name.as_str(), d)).collect();
86 let mut resolved: HashMap<String, Option<String>> = HashMap::new();
87 let mut visiting: Vec<String> = Vec::new();
88 for decl in decls {
89 resolve_one(
90 &decl.name,
91 &by_name,
92 supply,
93 scope,
94 &mut resolved,
95 &mut visiting,
96 )?;
97 }
98 Ok(resolved)
99}
100
101fn resolve_one(
102 name: &str,
103 by_name: &HashMap<&str, &SlotDecl>,
104 supply: &SlotSupply,
105 scope: &str,
106 resolved: &mut HashMap<String, Option<String>>,
107 visiting: &mut Vec<String>,
108) -> Result<Option<String>, DispatchError> {
109 if let Some(done) = resolved.get(name) {
110 return Ok(done.clone());
111 }
112 if visiting.iter().any(|v| v == name) {
113 let mut chain = visiting.clone();
114 chain.push(name.to_string());
115 return Err(DispatchError(format!(
116 "slot '{name}' in {scope} is defined in terms of itself: a variant's text references a \
117 slot whose own variant references it back (chain: {}).",
118 chain.join(" -> ")
119 )));
120 }
121 let decl = by_name
122 .get(name)
123 .expect("caller resolves declared names only");
124 let choice = supply.get(name);
125
126 if let Some(None) = choice {
127 resolved.insert(name.to_string(), None);
128 return Ok(None);
129 }
130 if choice.is_none() && decl.present_when.is_some() {
131 return Err(DispatchError(format!(
136 "slot '{name}' in {scope} declares a present_when condition, and nothing answered it. \
137 Supply the slot as a variant name to include it, or as absent-with-null to remove it; \
138 falling back to the default would ship wording for something that may not be there."
139 )));
140 }
141 let key = match choice {
142 Some(Some(variant)) => variant.as_str(),
143 _ => decl.default.as_str(),
144 };
145 let Some(text) = decl.variants.get(key) else {
146 return Err(DispatchError(format!(
147 "slot '{name}' in {scope} was given variant '{key}', which it does not declare \
148 (declared: {}).",
149 decl.variants.keys().cloned().collect::<Vec<_>>().join(", ")
150 )));
151 };
152
153 visiting.push(name.to_string());
154 let expanded = substitute(text, by_name, supply, scope, resolved, visiting)?;
155 visiting.pop();
156 resolved.insert(name.to_string(), Some(expanded.clone()));
157 Ok(Some(expanded))
158}
159
160fn substitute(
161 raw: &str,
162 by_name: &HashMap<&str, &SlotDecl>,
163 supply: &SlotSupply,
164 scope: &str,
165 resolved: &mut HashMap<String, Option<String>>,
166 visiting: &mut Vec<String>,
167) -> Result<String, DispatchError> {
168 let refs = slot_references(raw);
169 if refs.is_empty() {
170 return Ok(raw.to_string());
171 }
172 let mut out = String::new();
173 let mut cursor = 0usize;
174 for (start, end, name) in refs {
175 if !by_name.contains_key(name.as_str()) {
176 return Err(DispatchError(format!(
177 "prompt text in {scope} references slot '{name}', which is neither one of the \
178 component's slots nor one of the profile's."
179 )));
180 }
181 out.push_str(&raw[cursor..start]);
182 if let Some(text) = resolve_one(&name, by_name, supply, scope, resolved, visiting)? {
183 out.push_str(&text);
184 }
185 cursor = end;
186 }
187 out.push_str(&raw[cursor..]);
188 Ok(out)
189}
190
191pub fn apply_slots(
196 raw: &str,
197 resolved: &HashMap<String, Option<String>>,
198 scope: &str,
199) -> Result<String, DispatchError> {
200 let refs = slot_references(raw);
201 if refs.is_empty() {
202 return Ok(raw.to_string());
203 }
204 let mut out = String::new();
205 let mut cursor = 0usize;
206 for (start, end, name) in refs {
207 let Some(text) = resolved.get(&name) else {
208 return Err(DispatchError(format!(
209 "prompt text in {scope} references slot '{name}', which is not declared there."
210 )));
211 };
212 out.push_str(&raw[cursor..start]);
213 if let Some(text) = text {
214 out.push_str(text);
215 }
216 cursor = end;
217 }
218 out.push_str(&raw[cursor..]);
219 Ok(out)
220}
221
222pub fn assert_no_slot_references(raw: &str, owner: &str) -> Result<(), DispatchError> {
229 let refs = slot_references(raw);
230 if refs.is_empty() {
231 return Ok(());
232 }
233 let mut names: Vec<String> = refs.into_iter().map(|(_, _, name)| name).collect();
234 let seen: HashSet<String> = names.drain(..).collect();
235 let mut names: Vec<String> = seen.into_iter().collect();
236 names.sort();
237 Err(DispatchError(format!(
238 "{owner} still contains unresolved slot reference(s) ({}) at dispatch. Prompt text must go \
239 through slot resolution before it is emitted; shipping the placeholder would put literal \
240 '{{{{ slot.… }}}}' in front of the model.",
241 names.join(", ")
242 )))
243}
244
245pub fn resolve_ir_json(raw: &str, supply: &SlotSupply) -> Result<String, DispatchError> {
260 let mut doc: serde_json::Value = serde_json::from_str(raw)
261 .map_err(|e| DispatchError(format!("failed to parse IR while resolving slots: {e}")))?;
262
263 let profile_slots = take_slot_decls(&mut doc, "the profile")?;
264 let Some(components) = doc.get_mut("components").and_then(|c| c.as_array_mut()) else {
265 return Ok(raw.to_string());
266 };
267 let mut touched = !profile_slots.is_empty();
268
269 for node in components.iter_mut() {
270 let id = node
271 .get("id")
272 .and_then(|v| v.as_str())
273 .unwrap_or("<unnamed>")
274 .to_string();
275 let scope = format!("component '{id}'");
276 let own = take_slot_decls(node, &scope)?;
277 if own.is_empty() && profile_slots.is_empty() {
278 continue;
279 }
280 touched = true;
281 let decls: Vec<SlotDecl> = profile_slots.iter().chain(own.iter()).cloned().collect();
282 let resolved = resolve_slots(&decls, supply, &scope)?;
283 apply_to_node(node, &resolved, &scope)?;
284 }
285
286 if !touched {
287 return Ok(raw.to_string());
288 }
289 serde_json::to_string_pretty(&doc).map_err(|e| {
290 DispatchError(format!(
291 "failed to re-serialize IR after resolving slots: {e}"
292 ))
293 })
294}
295
296fn take_slot_decls(
298 value: &mut serde_json::Value,
299 scope: &str,
300) -> Result<Vec<SlotDecl>, DispatchError> {
301 let Some(object) = value.as_object_mut() else {
302 return Ok(Vec::new());
303 };
304 let Some(raw) = object.remove("slots") else {
305 return Ok(Vec::new());
306 };
307 serde_json::from_value(raw)
308 .map_err(|e| DispatchError(format!("malformed slots declaration on {scope}: {e}")))
309}
310
311fn apply_to_node(
317 node: &mut serde_json::Value,
318 resolved: &HashMap<String, Option<String>>,
319 scope: &str,
320) -> Result<(), DispatchError> {
321 for field in ["brief", "prompt_fragment"] {
322 if let Some(text) = node.get(field).and_then(|v| v.as_str()) {
323 let next = apply_slots(text, resolved, scope)?;
324 node[field] = serde_json::Value::String(next);
325 }
326 }
327 if let Some(calls) = node.get_mut("llm_calls").and_then(|c| c.as_array_mut()) {
328 for call in calls.iter_mut() {
329 if let Some(text) = call.get("prompt").and_then(|v| v.as_str()) {
330 let next = apply_slots(text, resolved, scope)?;
331 call["prompt"] = serde_json::Value::String(next);
332 }
333 }
334 }
335 Ok(())
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 fn decl(name: &str, default: &str, variants: &[(&str, &str)], conditional: bool) -> SlotDecl {
343 SlotDecl {
344 name: name.to_string(),
345 default: default.to_string(),
346 variants: variants
347 .iter()
348 .map(|(k, v)| (k.to_string(), v.to_string()))
349 .collect(),
350 present_when: conditional.then(|| serde_json::json!({"flag": "x"})),
351 }
352 }
353
354 fn supply(pairs: &[(&str, Option<&str>)]) -> SlotSupply {
355 pairs
356 .iter()
357 .map(|(k, v)| (k.to_string(), v.map(str::to_string)))
358 .collect()
359 }
360
361 const SCOPE: &str = "component 'c'";
362
363 #[test]
364 fn recognises_a_reference_with_and_without_inner_spacing() {
365 let decls = [decl("policy", "base", &[("base", "BASE")], false)];
366 let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
367 for raw in [
368 "{{slot.policy}}",
369 "{{ slot.policy }}",
370 "{{ slot.policy }}",
371 ] {
372 assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), "BASE");
373 }
374 }
375
376 #[test]
377 fn leaves_an_unterminated_brace_alone_like_the_compiler_does() {
378 let decls = [decl("policy", "base", &[("base", "BASE")], false)];
379 let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
380 let raw = "keep {{ slot.policy";
384 assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
385 }
386
387 #[test]
388 fn leaves_other_placeholders_untouched() {
389 let decls = [decl("policy", "base", &[("base", "BASE")], false)];
390 let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
391 let raw = "{{project}} / {{project_name}}";
392 assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
393 }
394
395 #[test]
396 fn a_name_the_compiler_would_reject_is_not_a_reference() {
397 let decls = [decl("policy", "base", &[("base", "BASE")], false)];
398 let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
399 let raw = "{{ slot.Policy }}";
400 assert_eq!(apply_slots(raw, &resolved, SCOPE).expect("applies"), raw);
401 }
402
403 #[test]
404 fn no_answer_renders_the_default_and_a_named_variant_wins() {
405 let decls = [decl(
406 "policy",
407 "base",
408 &[("base", "BASE"), ("terse", "T")],
409 false,
410 )];
411 let default = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
412 assert_eq!(
413 apply_slots("[{{ slot.policy }}]", &default, SCOPE).expect("applies"),
414 "[BASE]"
415 );
416 let chosen =
417 resolve_slots(&decls, &supply(&[("policy", Some("terse"))]), SCOPE).expect("resolves");
418 assert_eq!(
419 apply_slots("[{{ slot.policy }}]", &chosen, SCOPE).expect("applies"),
420 "[T]"
421 );
422 }
423
424 #[test]
425 fn none_removes_the_slot_rather_than_falling_back() {
426 let decls = [decl("policy", "base", &[("base", "BASE")], false)];
427 let resolved =
428 resolve_slots(&decls, &supply(&[("policy", None)]), SCOPE).expect("resolves");
429 assert_eq!(
430 apply_slots("[{{ slot.policy }}]", &resolved, SCOPE).expect("applies"),
431 "[]"
432 );
433 }
434
435 #[test]
436 fn an_unanswered_condition_is_a_loud_failure() {
437 let decls = [decl("policy", "base", &[("base", "BASE")], true)];
438 let err = resolve_slots(&decls, &supply(&[]), SCOPE).expect_err("must refuse");
439 assert!(
440 err.0
441 .contains("declares a present_when condition, and nothing answered it"),
442 "unexpected message: {}",
443 err.0
444 );
445 }
446
447 #[test]
448 fn an_undeclared_variant_is_refused_naming_the_declared_ones() {
449 let decls = [decl(
450 "policy",
451 "base",
452 &[("base", "BASE"), ("terse", "T")],
453 false,
454 )];
455 let err = resolve_slots(&decls, &supply(&[("policy", Some("nope"))]), SCOPE)
456 .expect_err("must refuse");
457 assert!(
458 err.0.contains("does not declare (declared: base, terse)"),
459 "unexpected: {}",
460 err.0
461 );
462 }
463
464 #[test]
465 fn a_variant_may_reference_another_slot() {
466 let decls = [
467 decl("outer", "only", &[("only", "<{{ slot.inner }}>")], false),
468 decl("inner", "only", &[("only", "IN")], false),
469 ];
470 let resolved = resolve_slots(&decls, &supply(&[]), SCOPE).expect("resolves");
471 assert_eq!(
472 apply_slots("[{{ slot.outer }}]", &resolved, SCOPE).expect("applies"),
473 "[<IN>]"
474 );
475 }
476
477 #[test]
478 fn a_cycle_between_variants_is_reported_not_spun_on() {
479 let decls = [
480 decl("a", "only", &[("only", "{{ slot.b }}")], false),
481 decl("b", "only", &[("only", "{{ slot.a }}")], false),
482 ];
483 let err = resolve_slots(&decls, &supply(&[]), SCOPE).expect_err("must refuse");
484 assert!(
485 err.0.contains("is defined in terms of itself"),
486 "unexpected: {}",
487 err.0
488 );
489 }
490
491 #[test]
492 fn the_guard_refuses_text_that_still_carries_a_reference() {
493 let err = assert_no_slot_references("a {{ slot.policy }} b", "the agent file")
494 .expect_err("must refuse");
495 assert!(
496 err.0.contains("unresolved slot reference(s) (policy)"),
497 "unexpected: {}",
498 err.0
499 );
500 assert_no_slot_references("{{project}} and a lone {{", "x").expect("not references");
501 }
502
503 const IR: &str = r#"{
504 "warble_ir_version": "0.7",
505 "profile": "p",
506 "slots": [{"name": "charter", "default": "base", "variants": {"base": "CHARTER", "alt": "ALT"}}],
507 "components": [{
508 "id": "c1",
509 "brief": "framing: {{ slot.charter }} / {{ slot.local }}",
510 "prompt_fragment": "frag {{ slot.local }}",
511 "llm_calls": [{"name": "s1", "prompt": "step {{ slot.charter }}"}],
512 "slots": [{"name": "local", "default": "d", "variants": {"d": "LOCAL"}}]
513 }]
514 }"#;
515
516 #[test]
517 fn resolves_both_layers_of_an_ir_document_and_drops_the_declarations() {
518 let out = resolve_ir_json(IR, &supply(&[("charter", Some("alt"))])).expect("resolves");
519 let doc: serde_json::Value = serde_json::from_str(&out).expect("valid json");
520 let node = &doc["components"][0];
521 assert_eq!(node["brief"], "framing: ALT / LOCAL");
522 assert_eq!(node["prompt_fragment"], "frag LOCAL");
523 assert_eq!(node["llm_calls"][0]["prompt"], "step ALT");
524 assert!(
525 doc.get("slots").is_none(),
526 "profile declarations are consumed, not carried"
527 );
528 assert!(
529 node.get("slots").is_none(),
530 "component declarations are consumed, not carried"
531 );
532 }
533
534 #[test]
535 fn an_ir_declaring_no_slots_is_returned_byte_identical() {
536 let raw = r#"{"warble_ir_version":"0.7","profile":"p","components":[{"id":"c1","brief":"plain"}]}"#;
537 assert_eq!(resolve_ir_json(raw, &supply(&[])).expect("resolves"), raw);
538 }
539}
540
541#[cfg(test)]
542mod emit_guard_tests {
543 use crate::ir::WarbleIr;
544 use std::path::Path;
545
546 #[test]
550 fn emit_refuses_an_ir_whose_prompt_text_still_holds_a_slot_reference() {
551 let raw = include_str!("../../../examples/analysis-agent/ir.golden.json");
552 let mut doc: serde_json::Value = serde_json::from_str(raw).expect("golden parses");
553 let node = &mut doc["components"][0];
554 let brief = node["brief"].as_str().unwrap_or("").to_string();
555 node["brief"] = serde_json::Value::String(format!("{brief}\n{{{{ slot.charter }}}}"));
556 let ir: WarbleIr = serde_json::from_value(doc).expect("still an IR");
557
558 let dir = tempfile::tempdir().expect("tempdir");
559 let err = crate::emit_claude_code(
560 &ir,
561 Path::new(dir.path()),
562 "claude-code:headless",
563 crate::DEFAULT_RENDER_FLAVOR,
564 )
565 .expect_err("must refuse an unresolved slot reference");
566 assert!(
567 err.0
568 .contains("still contains unresolved slot reference(s) (charter)"),
569 "unexpected message: {}",
570 err.0
571 );
572 }
573}