1use regex::Regex;
16use serde::{Deserialize, Serialize};
17
18use crate::core::ToolInvocation;
19use crate::validation::{SchemaName, ValidationError, validate_against_schema};
20
21use super::capabilities::{ShadowPreflight, SlugCapability, TranscriptParser};
22use super::extract::ExtractSpec;
23use super::harness::TokenUsageAggregation;
24use super::transcript::TranscriptSummary;
25
26pub mod layers;
27mod validation;
28
29pub const EMBEDDED_DESCRIPTORS: [(&str, &str); 3] = [
32 (
33 "harnesses/claude-code.toml",
34 include_str!("../../harnesses/claude-code.toml"),
35 ),
36 (
37 "harnesses/codex.toml",
38 include_str!("../../harnesses/codex.toml"),
39 ),
40 (
41 "harnesses/opencode.toml",
42 include_str!("../../harnesses/opencode.toml"),
43 ),
44];
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct HarnessDescriptor {
53 pub label: String,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub skills_dir: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub config_dirs: Vec<String>,
58 #[serde(default, skip_serializing_if = "RunSection::is_default")]
59 pub run: RunSection,
60 #[serde(default, skip_serializing_if = "ToolsSection::is_empty")]
61 pub tools: ToolsSection,
62 #[serde(default, skip_serializing_if = "StagingSection::is_unconfigured")]
63 pub staging: StagingSection,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub skills_block: Option<SkillsBlockSection>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub transcript: Option<TranscriptSection>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub model: Option<ModelSection>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub guard: Option<GuardSection>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub shadow: Option<ShadowSection>,
74 #[serde(default, skip_serializing_if = "DispatchSection::is_empty")]
75 pub dispatch: DispatchSection,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub conversation: Option<ConversationSection>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
83pub struct RunSection {
84 #[serde(default)]
85 pub supports_guard: bool,
86 #[serde(default = "default_true")]
87 pub supports_bootstrap_with_no_stage: bool,
88 #[serde(default = "default_true")]
89 pub supports_stage_name_with_no_stage: bool,
90}
91
92impl RunSection {
93 pub fn is_default(&self) -> bool {
96 *self == RunSection::default()
97 }
98}
99
100impl Default for RunSection {
101 fn default() -> Self {
102 RunSection {
103 supports_guard: false,
104 supports_bootstrap_with_no_stage: true,
105 supports_stage_name_with_no_stage: true,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Default, Deserialize, Serialize)]
111pub struct ToolsSection {
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub write: Vec<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub patch: Vec<String>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub shell: Vec<String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub read: Vec<String>,
120}
121
122impl ToolsSection {
123 pub fn is_empty(&self) -> bool {
125 self.write.is_empty()
126 && self.patch.is_empty()
127 && self.shell.is_empty()
128 && self.read.is_empty()
129 }
130}
131
132#[derive(Debug, Clone, Default, Deserialize, Serialize)]
133pub struct StagingSection {
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub slug_template: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub slug_capability: Option<SlugCapability>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub stage_name_pattern: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub stage_name_max_len: Option<usize>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub stage_name_invalid_message: Option<String>,
144 #[serde(default, skip_serializing_if = "is_false")]
145 pub rewrites_frontmatter_name: bool,
146 #[serde(default, skip_serializing_if = "is_false")]
147 pub advertises_staged_slug_name: bool,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub surface_phrase: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub unresolved_phrase: Option<String>,
152}
153
154impl StagingSection {
155 pub fn is_configured(&self) -> bool {
158 !self.is_unconfigured()
159 }
160
161 fn is_unconfigured(&self) -> bool {
162 self.slug_template.is_none()
163 && self.slug_capability.is_none()
164 && self.stage_name_pattern.is_none()
165 && self.stage_name_max_len.is_none()
166 && self.stage_name_invalid_message.is_none()
167 && !self.rewrites_frontmatter_name
168 && !self.advertises_staged_slug_name
169 && self.surface_phrase.is_none()
170 && self.unresolved_phrase.is_none()
171 }
172}
173
174#[derive(Debug, Clone, Deserialize, Serialize)]
175pub struct SkillsBlockSection {
176 pub header: String,
177 pub item: String,
178 #[serde(default, skip_serializing_if = "String::is_empty")]
179 pub footer: String,
180}
181
182#[derive(Debug, Clone, Deserialize, Serialize)]
183pub struct TranscriptSection {
184 pub events_filename: String,
185 #[serde(skip_serializing_if = "Option::is_none")]
188 pub parser: Option<TranscriptParser>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub extract: Option<ExtractSpec>,
192 #[serde(default = "default_true", skip_serializing_if = "is_true")]
193 pub surfaces_skill_invocation: bool,
194 #[serde(skip_serializing_if = "Option::is_none")]
198 pub skill_tool: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
202 pub skill_arg: Option<String>,
203}
204
205impl TranscriptSection {
206 pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
209 match (&self.parser, &self.extract) {
210 (Some(parser), _) => parser.parse(path),
211 (None, Some(extract)) => super::extract::parse(extract, path),
212 (None, None) => Err(unwired_error()),
215 }
216 }
217
218 pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
220 match (&self.parser, &self.extract) {
221 (Some(parser), _) => parser.parse_full(path),
222 (None, Some(extract)) => super::extract::parse_full(extract, path),
223 (None, None) => Err(unwired_error()),
224 }
225 }
226}
227
228fn unwired_error() -> std::io::Error {
229 std::io::Error::new(
230 std::io::ErrorKind::Unsupported,
231 "[transcript] declares neither a parser nor an extract block",
232 )
233}
234
235#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct ModelSection {
237 pub flag: String,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
245#[serde(rename_all = "kebab-case")]
246pub enum GuardEngine {
247 #[default]
248 JsonHooks,
249 OpencodePlugin,
250}
251
252impl GuardEngine {
253 fn is_default(&self) -> bool {
256 *self == GuardEngine::default()
257 }
258}
259
260#[derive(Debug, Clone, Deserialize, Serialize)]
270pub struct GuardSection {
271 #[serde(default, skip_serializing_if = "GuardEngine::is_default")]
273 pub engine: GuardEngine,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub hooks_file: Option<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub matcher: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub command_template: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub hook_entry: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub plugin_file: Option<String>,
297 pub verdict_template: String,
300 pub armed_message: String,
302}
303
304#[derive(Debug, Clone, Deserialize, Serialize)]
305pub struct ShadowSection {
306 pub preflight: ShadowPreflight,
307}
308
309#[derive(Debug, Clone, Default, Deserialize, Serialize)]
310pub struct DispatchSection {
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub capture_prefix: Option<String>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub guard_args: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub model_note: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub next_steps_template: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub exec_template: Option<String>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub parallel_command_template: Option<String>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub judge_command_template: Option<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub manifest_template: Option<String>,
327}
328
329#[derive(Debug, Clone, Deserialize, Serialize)]
331pub struct ConversationSection {
332 pub resume_exec_template: String,
336 #[serde(default, skip_serializing_if = "token_usage_aggregation_is_sum")]
339 pub token_usage_aggregation: TokenUsageAggregation,
340}
341
342fn token_usage_aggregation_is_sum(value: &TokenUsageAggregation) -> bool {
343 *value == TokenUsageAggregation::Sum
344}
345
346impl DispatchSection {
347 pub fn is_empty(&self) -> bool {
349 self.capture_prefix.is_none()
350 && self.guard_args.is_none()
351 && self.model_note.is_none()
352 && self.next_steps_template.is_none()
353 && self.exec_template.is_none()
354 && self.parallel_command_template.is_none()
355 && self.judge_command_template.is_none()
356 && self.manifest_template.is_none()
357 }
358}
359
360fn default_true() -> bool {
361 true
362}
363
364fn is_false(value: &bool) -> bool {
366 !*value
367}
368
369fn is_true(value: &bool) -> bool {
370 *value
371}
372
373#[derive(Debug, thiserror::Error)]
376pub enum DescriptorError {
377 #[error("{path}: invalid TOML: {message}")]
378 Toml { path: String, message: String },
379 #[error(transparent)]
380 Validation(#[from] ValidationError),
381 #[error("{path}: {message}")]
382 Invariant { path: String, message: String },
383}
384
385pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
388 finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
389}
390
391pub fn parse_descriptor_value(
397 toml_src: &str,
398 source: &str,
399) -> Result<serde_json::Value, DescriptorError> {
400 let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
401 path: source.to_string(),
402 message: e.to_string(),
403 })?;
404 let _: serde_json::Value =
405 validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
406 Ok(value)
407}
408
409pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
414 match (base, overlay) {
415 (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
416 for (key, value) in overlay {
417 match base.get_mut(&key) {
418 Some(slot) if slot.is_object() && value.is_object() => {
419 merge_descriptor_value(slot, value);
420 }
421 _ => {
422 base.insert(key, value);
423 }
424 }
425 }
426 }
427 (base, overlay) => *base = overlay,
428 }
429}
430
431pub fn finalize_descriptor(
435 value: &serde_json::Value,
436 provenance: &str,
437) -> Result<HarnessDescriptor, DescriptorError> {
438 let descriptor: HarnessDescriptor =
439 validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
440 validation::validate_descriptor(&descriptor, provenance)?;
441 Ok(descriptor)
442}
443
444pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
450 let mut out = String::with_capacity(template.len());
451 let mut rest = template;
452 while let Some(start) = rest.find('{') {
453 out.push_str(&rest[..start]);
454 let after = &rest[start + 1..];
455 let Some(end) = after.find('}') else {
456 out.push('{');
457 rest = after;
458 continue;
459 };
460 match vars.iter().find(|(k, _)| *k == &after[..end]) {
461 Some((_, value)) => {
462 out.push_str(value);
463 rest = &after[end + 1..];
464 }
465 None => {
466 out.push('{');
467 rest = after;
468 }
469 }
470 }
471 out.push_str(rest);
472 out
473}
474
475pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
478
479pub(crate) fn render_staged_slug(
482 staging: &StagingSection,
483 prefix: &str,
484 iteration: u32,
485 condition: &str,
486 skill_name: &str,
487) -> String {
488 if let Some(capability) = staging.slug_capability {
489 return capability.staged_slug(prefix, iteration, condition, skill_name);
490 }
491 let iteration = iteration.to_string();
492 subst(
493 staging
494 .slug_template
495 .as_deref()
496 .unwrap_or(DEFAULT_SLUG_TEMPLATE),
497 &[
498 ("prefix", prefix),
499 ("iteration", &iteration),
500 ("condition", condition),
501 ("skill_name", skill_name),
502 ],
503 )
504}
505
506pub(crate) fn stage_name_error(
509 staging: &StagingSection,
510 regex: Option<&Regex>,
511 name: &str,
512) -> Option<String> {
513 let len_ok = staging
514 .stage_name_max_len
515 .is_none_or(|max| name.len() <= max);
516 let pattern_ok = regex.is_none_or(|r| r.is_match(name));
517 if len_ok && pattern_ok {
518 return None;
519 }
520 Some(match &staging.stage_name_invalid_message {
521 Some(message) => subst(message, &[("name", name)]),
522 None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
523 })
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
531 load_descriptor(toml_src, "test.toml")
532 }
533
534 fn err_of(toml_src: &str) -> String {
535 load(toml_src)
536 .expect_err("descriptor should be rejected")
537 .to_string()
538 }
539
540 const MINIMAL: &str = r#"
541label = "demo"
542skills_dir = ".demo/skills"
543config_dirs = [".demo"]
544"#;
545
546 const GUARDED: &str = r#"
547label = "demo"
548skills_dir = ".demo/skills"
549config_dirs = [".demo"]
550
551[run]
552supports_guard = true
553
554[tools]
555write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
556shell = ["Bash"]
557
558[guard]
559hooks_file = ".demo/hooks.json"
560matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
561command_template = '"{exe}" guard-hook --harness demo "{marker}"'
562hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
563verdict_template = '{"decision":"block","reason":"{reason}"}'
564armed_message = "guard armed"
565"#;
566
567 const EXTRACTED: &str = r#"
569label = "demo"
570skills_dir = ".demo/skills"
571config_dirs = [".demo"]
572
573[tools]
574write = ["file_change"]
575shell = ["command_execution"]
576
577[transcript]
578events_filename = "demo-events.jsonl"
579
580[transcript.extract.tools]
581where = { type = "item.completed" }
582item = "item"
583name_field = "type"
584skip_names = ["agent_message"]
585args_omit = ["id", "type", "output"]
586result_coalesce = ["output"]
587
588[transcript.extract.final_text]
589where = { type = "item.completed", "item.type" = "agent_message" }
590field = "item.text"
591
592[transcript.extract.tokens]
593where = { type = "turn.completed" }
594sum = ["usage.input_tokens", "usage.output_tokens"]
595subtract = ["usage.cached_input_tokens"]
596
597[transcript.extract.duration]
598timestamp_spread = "timestamp"
599"#;
600
601 #[test]
602 fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
603 let d = load(EXTRACTED).unwrap();
604 let transcript = d.transcript.as_ref().expect("transcript section loads");
605 assert!(transcript.parser.is_none());
606 assert!(transcript.extract.is_some());
607
608 let shown = toml::to_string(&d).expect("descriptor re-serializes");
611 let reloaded = load(&shown).unwrap();
612 let extract = reloaded.transcript.unwrap().extract.unwrap();
613 let tokens = extract.tokens.unwrap();
614 assert_eq!(
615 tokens.sum,
616 vec!["usage.input_tokens", "usage.output_tokens"]
617 );
618 assert_eq!(tokens.subtract, vec!["usage.cached_input_tokens"]);
619 assert_eq!(
620 extract
621 .tools
622 .unwrap()
623 .r#where
624 .get("type")
625 .map(String::as_str),
626 Some("item.completed")
627 );
628 }
629
630 #[test]
631 fn transcript_section_dispatches_parse_to_the_extract_engine() {
632 let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
633 let dir = tempfile::TempDir::new().unwrap();
634 let path = dir.path().join("demo-events.jsonl");
635 std::fs::write(
636 &path,
637 concat!(
638 r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
639 "\n",
640 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
641 "\n",
642 ),
643 )
644 .unwrap();
645
646 let invocations = transcript.parse(&path).unwrap();
647 assert_eq!(invocations.len(), 1);
648 assert_eq!(invocations[0].name, "command_execution");
649
650 let full = transcript.parse_full(&path).unwrap();
651 assert_eq!(full.final_text, Some("Done.".into()));
652 assert_eq!(full.tool_invocations.len(), 1);
653 }
654
655 #[test]
656 fn minimal_descriptor_loads() {
657 let d = load(MINIMAL).unwrap();
658 assert_eq!(d.label, "demo");
659 assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
660 assert_eq!(d.config_dirs, vec![".demo".to_string()]);
661 assert!(!d.run.supports_guard);
663 assert!(d.run.supports_bootstrap_with_no_stage);
664 assert!(d.run.supports_stage_name_with_no_stage);
665 assert!(!d.staging.rewrites_frontmatter_name);
666 assert!(d.guard.is_none());
667 assert!(d.transcript.is_none());
668 }
669
670 #[test]
671 fn guarded_descriptor_loads() {
672 let d = load(GUARDED).unwrap();
673 assert!(d.run.supports_guard);
674 let guard = d.guard.expect("guard section loads");
675 assert_eq!(guard.engine, GuardEngine::JsonHooks);
676 assert_eq!(guard.hooks_file.as_deref(), Some(".demo/hooks.json"));
677 assert_eq!(
678 guard.matcher.as_deref(),
679 Some("Write|Edit|MultiEdit|NotebookEdit|Bash")
680 );
681 assert_eq!(
682 guard.command_template.as_deref(),
683 Some(r#""{exe}" guard-hook --harness demo "{marker}""#)
684 );
685 assert_eq!(guard.plugin_file, None);
686 assert_eq!(guard.armed_message, "guard armed");
687 }
688
689 #[test]
690 fn label_only_descriptor_loads_as_baseline() {
691 let d = load("label = \"demo\"\n").unwrap();
692 assert_eq!(d.label, "demo");
693 assert!(d.skills_dir.is_none(), "no skills dir declared");
694 assert!(d.config_dirs.is_empty());
695 assert!(!d.run.supports_guard);
696 }
697
698 #[test]
699 fn rejects_staging_without_skills_dir() {
700 let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
701 assert!(err.contains("staging"), "{err}");
702 assert!(err.contains("skills_dir"), "{err}");
703 }
704
705 #[test]
706 fn rejects_guard_without_skills_dir() {
707 let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
708 let err = err_of(toml);
709 assert!(err.contains("guard"), "{err}");
710 assert!(err.contains("skills_dir"), "{err}");
711 }
712
713 #[test]
714 fn embedded_descriptors_load_and_validate() {
715 for (source, toml_src) in EMBEDDED_DESCRIPTORS {
716 let d = load_descriptor(toml_src, source)
717 .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
718 assert!(!d.label.is_empty());
719 }
720 }
721
722 #[test]
723 fn rejects_invalid_toml_syntax() {
724 let err = err_of("label = ");
725 assert!(err.contains("test.toml"), "{err}");
726 assert!(err.contains("invalid TOML"), "{err}");
727 }
728
729 #[test]
730 fn rejects_unknown_top_level_field() {
731 let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
732 assert!(err.contains("harness-descriptor schema"), "{err}");
733 }
734
735 #[test]
736 fn rejects_non_kebab_case_label() {
737 let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
738 assert!(err.contains("harness-descriptor schema"), "{err}");
739 }
740
741 #[test]
747 fn rejects_the_retired_guard_engine_field() {
748 let err = err_of(&GUARDED.replace(
749 "hooks_file = \".demo/hooks.json\"",
750 "engine = \"claude-hooks\"",
751 ));
752 assert!(err.contains("harness-descriptor schema"), "{err}");
753 }
754
755 #[test]
756 fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
757 let mut base: serde_json::Value = toml::from_str(
758 r#"
759label = "demo"
760config_dirs = [".demo"]
761
762[model]
763flag = "--model"
764
765[dispatch]
766capture_prefix = "demo"
767"#,
768 )
769 .unwrap();
770 let overlay: serde_json::Value = toml::from_str(
771 r#"
772label = "demo"
773config_dirs = [".other"]
774
775[model]
776flag = "--model-x"
777"#,
778 )
779 .unwrap();
780 merge_descriptor_value(&mut base, overlay);
781 assert_eq!(base["model"]["flag"], "--model-x");
784 assert_eq!(base["dispatch"]["capture_prefix"], "demo");
785 assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
786 }
787
788 #[test]
789 fn finalize_names_every_contributing_file_on_invariant_breaks() {
790 let value: serde_json::Value =
793 toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
794 let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
795 .expect_err("missing config_dirs parent should be rejected")
796 .to_string();
797 assert!(
798 err.contains("base.toml (built-in) + overlay.toml (project)"),
799 "{err}"
800 );
801 }
802
803 #[test]
804 fn subst_replaces_tokens_and_passes_unknown_through() {
805 let out = subst(
806 "run {exec} with ${JOBS:-4} and -I{} on {exec}",
807 &[("exec", "demo-cmd")],
808 );
809 assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
810 }
811
812 #[test]
813 fn subst_does_not_rescan_substituted_values() {
814 let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
815 assert_eq!(out, "holds-{b} second");
816 }
817}