1use std::path::Path;
11use std::sync::{LazyLock, OnceLock};
12
13use crate::core::Harness;
14
15use super::descriptor::layers::{
16 DescriptorSource, HarnessFileError, Layer, check_user_layer_restrictions, default_config_root,
17 discover_sources, embedded_sources,
18};
19use super::descriptor::{
20 DescriptorError, HarnessDescriptor, finalize_descriptor, merge_descriptor_value,
21 parse_descriptor_value,
22};
23use super::descriptor_adapter::DescriptorAdapter;
24use super::harness::{HarnessAdapter, ToolVocabulary};
25
26#[derive(Debug)]
31struct RegistryEntry {
32 label: &'static str,
33 sources: Vec<(Layer, String)>,
34 value: serde_json::Value,
35 adapter: DescriptorAdapter,
36}
37
38impl RegistryEntry {
39 fn has_embedded_layer(&self) -> bool {
42 self.sources
43 .iter()
44 .any(|(layer, _)| *layer == Layer::Embedded)
45 }
46}
47
48static REGISTRY: OnceLock<Vec<RegistryEntry>> = OnceLock::new();
53
54fn registry() -> &'static Vec<RegistryEntry> {
55 REGISTRY.get_or_init(|| {
56 build_registry(embedded_sources())
61 .unwrap_or_else(|e| panic!("bundled harness descriptor is invalid: {e}"))
62 .entries
63 })
64}
65
66#[derive(Debug, thiserror::Error)]
68pub enum RegistryInitError {
69 #[error(transparent)]
70 HarnessFile(#[from] HarnessFileError),
71 #[error(transparent)]
72 Build(#[from] RegistryBuildError),
73 #[error("the harness registry is already initialized")]
74 AlreadyInitialized,
75}
76
77static SESSION_DEFAULT_HARNESS: OnceLock<&'static str> = OnceLock::new();
81
82pub fn init_registry(harness_file: Option<&Path>) -> Result<(), RegistryInitError> {
93 let project_root = std::env::current_dir().unwrap_or_default();
94 let (sources, io_warnings) = discover_sources(
95 default_config_root().as_deref(),
96 &project_root,
97 harness_file,
98 )?;
99 let built = build_registry(sources)?;
100 for warning in io_warnings.iter().chain(&built.warnings) {
101 eprintln!("⚠ {warning}");
102 }
103 if harness_file.is_some()
104 && let Some(entry) = built
105 .entries
106 .iter()
107 .find(|e| e.sources.iter().any(|(l, _)| *l == Layer::HarnessFile))
108 {
109 let _ = SESSION_DEFAULT_HARNESS.set(entry.label);
110 }
111 REGISTRY
112 .set(built.entries)
113 .map_err(|_| RegistryInitError::AlreadyInitialized)
114}
115
116#[derive(Debug, thiserror::Error)]
121pub enum RegistryBuildError {
122 #[error(transparent)]
123 Descriptor(#[from] DescriptorError),
124 #[error("duplicate harness label {label:?}: {first} and {second}")]
125 DuplicateLabel {
126 label: String,
127 first: String,
128 second: String,
129 },
130}
131
132#[derive(Debug)]
135struct BuiltRegistry {
136 entries: Vec<RegistryEntry>,
137 warnings: Vec<String>,
138}
139
140struct PendingEntry {
142 label: String,
143 value: serde_json::Value,
144 descriptor: HarnessDescriptor,
145 sources: Vec<(Layer, String)>,
146}
147
148fn build_registry(sources: Vec<DescriptorSource>) -> Result<BuiltRegistry, RegistryBuildError> {
163 let mut pending: Vec<PendingEntry> = Vec::new();
164 let mut warnings: Vec<String> = Vec::new();
165 for source in sources {
166 let strict = matches!(source.layer, Layer::Embedded | Layer::HarnessFile);
167 let mut fail_soft = |e: RegistryBuildError| -> Result<(), RegistryBuildError> {
168 if strict {
169 Err(e)
170 } else {
171 warnings.push(format!(
172 "skipping harness descriptor: {e}\n (run `eval-magic harness lint <file>` \
173 for the full report)"
174 ));
175 Ok(())
176 }
177 };
178
179 let value = match parse_descriptor_value(&source.toml_src, &source.path) {
180 Ok(value) => value,
181 Err(e) => {
182 fail_soft(e.into())?;
183 continue;
184 }
185 };
186 if source.layer != Layer::Embedded
187 && let Err(e) = check_user_layer_restrictions(&value, &source.path)
188 {
189 fail_soft(e.into())?;
190 continue;
191 }
192 let label = value
193 .get("label")
194 .and_then(serde_json::Value::as_str)
195 .expect("the schema gate requires a string label")
196 .to_string();
197
198 match pending.iter().position(|p| p.label == label) {
199 None => match finalize_descriptor(&value, &source.path) {
200 Ok(descriptor) => pending.push(PendingEntry {
201 label,
202 value,
203 descriptor,
204 sources: vec![(source.layer, source.path)],
205 }),
206 Err(e) => fail_soft(e.into())?,
207 },
208 Some(index) => {
209 let entry = &mut pending[index];
210 let (last_layer, last_path) = entry
211 .sources
212 .last()
213 .expect("every entry records its source");
214 if *last_layer == source.layer {
215 fail_soft(RegistryBuildError::DuplicateLabel {
216 label,
217 first: last_path.clone(),
218 second: source.path,
219 })?;
220 continue;
221 }
222 let mut merged = entry.value.clone();
223 merge_descriptor_value(&mut merged, value);
224 let provenance = provenance_chain(&entry.sources, &source);
225 match finalize_descriptor(&merged, &provenance) {
226 Ok(descriptor) => {
227 entry.value = merged;
228 entry.descriptor = descriptor;
229 entry.sources.push((source.layer, source.path));
230 }
231 Err(e) => fail_soft(e.into())?,
232 }
233 }
234 }
235 }
236
237 let entries = pending
238 .into_iter()
239 .map(|p| RegistryEntry {
240 label: Box::leak(p.label.into_boxed_str()),
244 sources: p.sources,
245 value: p.value,
246 adapter: DescriptorAdapter::from_descriptor(p.descriptor),
247 })
248 .collect();
249 Ok(BuiltRegistry { entries, warnings })
250}
251
252fn provenance_chain(sources: &[(Layer, String)], next: &DescriptorSource) -> String {
255 sources
256 .iter()
257 .map(|(layer, path)| format!("{path} ({})", layer.display_name()))
258 .chain([format!("{} ({})", next.path, next.layer.display_name())])
259 .collect::<Vec<_>>()
260 .join(" + ")
261}
262
263pub const DEFAULT_HARNESS_NAME: &str = "claude-code";
268
269#[derive(Debug, thiserror::Error)]
273#[error("unknown harness '{name}'; known harnesses: {}", known.join(", "))]
274pub struct UnknownHarnessError {
275 pub name: String,
276 pub known: Vec<&'static str>,
277}
278
279impl Harness {
280 pub fn resolve(name: &str) -> Result<Harness, UnknownHarnessError> {
283 registry()
284 .iter()
285 .find(|e| e.label == name)
286 .map(|e| Harness::from_static_name(e.label))
287 .ok_or_else(|| UnknownHarnessError {
288 name: name.to_string(),
289 known: Harness::known().map(Harness::name).collect(),
290 })
291 }
292
293 pub fn known() -> impl Iterator<Item = Harness> {
297 registry()
298 .iter()
299 .map(|e| Harness::from_static_name(e.label))
300 }
301}
302
303impl Default for Harness {
304 fn default() -> Self {
305 Harness::resolve(default_harness_name())
308 .expect("the session default resolves against the registry")
309 }
310}
311
312impl<'de> serde::Deserialize<'de> for Harness {
313 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
314 let name = String::deserialize(deserializer)?;
315 Harness::resolve(&name).map_err(serde::de::Error::custom)
316 }
317}
318
319pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
323 ®istry()
324 .iter()
325 .find(|e| e.label == harness.name())
326 .expect("Harness handles originate from the registry")
327 .adapter
328}
329
330pub fn descriptor_value_for(harness: Harness) -> &'static serde_json::Value {
333 ®istry()
334 .iter()
335 .find(|entry| entry.label == harness.name())
336 .expect("Harness handles originate from the registry")
337 .value
338}
339
340pub fn has_embedded_layer(harness: Harness) -> bool {
345 registry()
346 .iter()
347 .find(|e| e.label == harness.name())
348 .expect("Harness handles originate from the registry")
349 .has_embedded_layer()
350}
351
352pub struct HarnessInfo {
356 pub label: &'static str,
357 pub sources: &'static [(Layer, String)],
358 pub value: &'static serde_json::Value,
359 pub descriptor: &'static HarnessDescriptor,
360}
361
362pub fn harness_info() -> impl Iterator<Item = HarnessInfo> {
365 registry().iter().map(|e| HarnessInfo {
366 label: e.label,
367 sources: &e.sources,
368 value: &e.value,
369 descriptor: e.adapter.descriptor(),
370 })
371}
372
373pub fn default_harness_name() -> &'static str {
376 SESSION_DEFAULT_HARNESS
377 .get()
378 .copied()
379 .unwrap_or(DEFAULT_HARNESS_NAME)
380}
381
382pub fn all_config_dir_names() -> Vec<String> {
387 let mut names: Vec<String> = registry()
388 .iter()
389 .flat_map(|e| e.adapter.config_dir_names())
390 .collect();
391 names.sort_unstable();
392 names.dedup();
393 names
394}
395
396pub fn all_tool_vocabulary() -> &'static ToolVocabulary {
400 static ALL: LazyLock<ToolVocabulary> = LazyLock::new(|| {
401 let mut union = ToolVocabulary::default();
402 for entry in registry().iter() {
403 let vocab = entry.adapter.tool_vocabulary();
404 union.write_tools.extend(vocab.write_tools);
405 union.patch_tools.extend(vocab.patch_tools);
406 union.shell_tools.extend(vocab.shell_tools);
407 union.read_tools.extend(vocab.read_tools);
408 }
409 for list in [
410 &mut union.write_tools,
411 &mut union.patch_tools,
412 &mut union.shell_tools,
413 &mut union.read_tools,
414 ] {
415 list.sort_unstable();
416 list.dedup();
417 }
418 union
419 });
420 &ALL
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 use crate::adapters::descriptor::EMBEDDED_DESCRIPTORS;
428 use crate::adapters::descriptor::layers::{Layer, embedded_sources};
429
430 #[test]
431 fn all_config_dir_names_unions_every_adapter() {
432 assert_eq!(
433 all_config_dir_names(),
434 [".agents", ".claude", ".codex", ".opencode"]
435 );
436 }
437
438 #[test]
439 fn all_tool_vocabulary_unions_every_adapter() {
440 let vocab = all_tool_vocabulary();
441 assert_eq!(
442 vocab.write_tools,
443 [
444 "Edit",
445 "MultiEdit",
446 "NotebookEdit",
447 "Write",
448 "edit",
449 "file_change",
450 "write"
451 ]
452 );
453 assert_eq!(vocab.patch_tools, ["apply_patch"]);
454 assert_eq!(vocab.shell_tools, ["Bash", "bash", "command_execution"]);
455 assert_eq!(
456 vocab.read_tools,
457 ["Glob", "Grep", "Read", "glob", "grep", "read"]
458 );
459 }
460
461 fn src(layer: Layer, path: &str, toml_src: &str) -> DescriptorSource {
462 DescriptorSource {
463 layer,
464 path: path.to_string(),
465 toml_src: toml_src.to_string(),
466 }
467 }
468
469 const USER_GUARD_TOML: &str = r#"
472label = "armed"
473
474[guard]
475hooks_file = ".armed/hooks.json"
476matcher = "Write"
477command_template = '"{exe}" guard-hook --harness armed "{marker}"'
478hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
479verdict_template = '{"decision":"block","reason":"{reason}"}'
480armed_message = "x"
481"#;
482
483 #[test]
484 fn duplicate_embedded_label_errors() {
485 let mut sources = embedded_sources();
486 sources.push(sources[0].clone());
487 let err = build_registry(sources).unwrap_err().to_string();
488 assert!(err.contains("duplicate harness label"), "{err}");
489 assert!(err.contains("claude-code"), "names the label: {err}");
490 assert!(
491 err.contains("harnesses/claude-code.toml"),
492 "names the colliding source files: {err}"
493 );
494 }
495
496 #[test]
497 fn registry_entries_record_embedded_provenance() {
498 let built = build_registry(embedded_sources()).unwrap();
499 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
500 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
501 for entry in &built.entries {
502 assert_eq!(entry.sources.len(), 1, "one contributing file per built-in");
503 assert_eq!(entry.sources[0].0, Layer::Embedded);
504 assert!(
505 entry.sources[0].1.contains(entry.label),
506 "source path names the harness: {}",
507 entry.sources[0].1
508 );
509 }
510 }
511
512 #[test]
513 fn project_layer_overrides_a_single_field_of_a_builtin() {
514 let mut sources = embedded_sources();
515 sources.push(src(
516 Layer::ProjectLocal,
517 ".eval-magic/harnesses/claude-code.toml",
518 "label = \"claude-code\"\n\n[model]\nflag = \"--model-x\"\n",
519 ));
520 let built = build_registry(sources).unwrap();
521 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
522 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
523 let entry = built
524 .entries
525 .iter()
526 .find(|e| e.label == "claude-code")
527 .unwrap();
528 assert_eq!(entry.adapter.cli_model_flag(), Some("--model-x".into()));
531 assert!(entry.adapter.run_capabilities().supports_guard);
532 assert_eq!(
533 entry.sources,
534 vec![
535 (Layer::Embedded, "harnesses/claude-code.toml".to_string()),
536 (
537 Layer::ProjectLocal,
538 ".eval-magic/harnesses/claude-code.toml".to_string()
539 ),
540 ]
541 );
542 }
543
544 #[test]
545 fn new_label_in_a_user_layer_registers_a_new_harness() {
546 let mut sources = embedded_sources();
547 sources.push(src(
548 Layer::ProjectLocal,
549 ".eval-magic/harnesses/cool.toml",
550 "label = \"cool-custom-harness\"\n",
551 ));
552 let built = build_registry(sources).unwrap();
553 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
554 let entry = built
555 .entries
556 .iter()
557 .find(|e| e.label == "cool-custom-harness")
558 .expect("new harness registered");
559 assert_eq!(entry.sources[0].0, Layer::ProjectLocal);
560 assert!(entry.adapter.skills_dir(Path::new("/r")).is_none());
561 }
562
563 #[test]
564 fn discovered_file_declaring_a_guard_warns_and_is_skipped() {
565 let mut sources = embedded_sources();
566 sources.push(src(
567 Layer::ProjectLocal,
568 ".eval-magic/harnesses/armed.toml",
569 USER_GUARD_TOML,
570 ));
571 let built = build_registry(sources).unwrap();
572 assert_eq!(
573 built.entries.len(),
574 EMBEDDED_DESCRIPTORS.len(),
575 "file skipped"
576 );
577 assert_eq!(built.warnings.len(), 1);
578 assert!(
579 built.warnings[0].contains("may not declare [guard]"),
580 "{}",
581 built.warnings[0]
582 );
583 }
584
585 #[test]
586 fn discovered_overlay_breaking_invariants_warns_and_keeps_the_base() {
587 let mut sources = embedded_sources();
588 sources.push(src(
591 Layer::ProjectLocal,
592 ".eval-magic/harnesses/claude-code.toml",
593 "label = \"claude-code\"\nconfig_dirs = [\".other\"]\n",
594 ));
595 let built = build_registry(sources).unwrap();
596 assert_eq!(built.warnings.len(), 1);
597 assert!(
598 built.warnings[0].contains(".eval-magic/harnesses/claude-code.toml"),
599 "names the offending file: {}",
600 built.warnings[0]
601 );
602 let entry = built
603 .entries
604 .iter()
605 .find(|e| e.label == "claude-code")
606 .unwrap();
607 assert_eq!(
608 entry.adapter.config_dir_names(),
609 vec![".claude".to_string()],
610 "embedded base survives the dropped overlay"
611 );
612 assert_eq!(
613 entry.sources.len(),
614 1,
615 "the bad overlay records no provenance"
616 );
617 }
618
619 #[test]
620 fn same_label_twice_in_one_discovered_layer_warns_and_skips_the_second() {
621 let mut sources = embedded_sources();
622 sources.push(src(
623 Layer::ProjectLocal,
624 "a.toml",
625 "label = \"claude-code\"\n\n[model]\nflag = \"--from-a\"\n",
626 ));
627 sources.push(src(
628 Layer::ProjectLocal,
629 "b.toml",
630 "label = \"claude-code\"\n\n[model]\nflag = \"--from-b\"\n",
631 ));
632 let built = build_registry(sources).unwrap();
633 assert_eq!(built.warnings.len(), 1);
634 assert!(
635 built.warnings[0].contains("a.toml"),
636 "{}",
637 built.warnings[0]
638 );
639 assert!(
640 built.warnings[0].contains("b.toml"),
641 "{}",
642 built.warnings[0]
643 );
644 let entry = built
645 .entries
646 .iter()
647 .find(|e| e.label == "claude-code")
648 .unwrap();
649 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-a".into()));
650 }
651
652 #[test]
653 fn harness_file_failures_are_fatal() {
654 let mut sources = embedded_sources();
655 sources.push(src(Layer::HarnessFile, "one-off.toml", "label = "));
656 let err = build_registry(sources).unwrap_err().to_string();
657 assert!(err.contains("one-off.toml"), "{err}");
658 }
659
660 #[test]
661 fn harness_file_guard_rejection_is_fatal() {
662 let mut sources = embedded_sources();
663 sources.push(src(Layer::HarnessFile, "one-off.toml", USER_GUARD_TOML));
664 let err = build_registry(sources).unwrap_err().to_string();
665 assert!(err.contains("may not declare [guard]"), "{err}");
666 }
667
668 #[test]
669 fn harness_file_overlay_merges_on_top_of_discovered_layers() {
670 let mut sources = embedded_sources();
671 sources.push(src(
672 Layer::ProjectLocal,
673 "p.toml",
674 "label = \"claude-code\"\n\n[model]\nflag = \"--from-project\"\n",
675 ));
676 sources.push(src(
677 Layer::HarnessFile,
678 "one-off.toml",
679 "label = \"claude-code\"\n\n[model]\nflag = \"--from-file\"\n",
680 ));
681 let built = build_registry(sources).unwrap();
682 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
683 let entry = built
684 .entries
685 .iter()
686 .find(|e| e.label == "claude-code")
687 .unwrap();
688 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-file".into()));
689 assert_eq!(entry.sources.len(), 3);
690 }
691
692 #[test]
693 fn embedded_layer_provenance_distinguishes_built_ins_from_user_only_harnesses() {
694 let mut sources = embedded_sources();
695 sources.push(src(
696 Layer::ProjectLocal,
697 ".eval-magic/harnesses/cool.toml",
698 "label = \"cool\"\n",
699 ));
700 let built = build_registry(sources).unwrap();
701 let entry = |label: &str| built.entries.iter().find(|e| e.label == label).unwrap();
702 assert!(
703 entry("claude-code").has_embedded_layer(),
704 "built-ins carry their embedded source"
705 );
706 assert!(
707 !entry("cool").has_embedded_layer(),
708 "a user-only harness has no embedded layer"
709 );
710 }
711
712 #[test]
713 fn resolve_unknown_name_lists_known_harnesses() {
714 let err = Harness::resolve("nonexistent").unwrap_err().to_string();
715 assert!(err.contains("unknown harness 'nonexistent'"), "{err}");
716 for name in ["claude-code", "codex", "opencode"] {
717 assert!(err.contains(name), "error must name {name}: {err}");
718 }
719 }
720
721 #[test]
722 fn resolve_round_trips_every_registry_entry() {
723 for harness in Harness::known() {
724 assert_eq!(Harness::resolve(harness.name()).unwrap(), harness);
725 }
726 }
727
728 #[test]
729 fn default_harness_is_claude_code() {
730 assert_eq!(DEFAULT_HARNESS_NAME, "claude-code");
731 assert_eq!(Harness::default().name(), DEFAULT_HARNESS_NAME);
732 }
733
734 #[test]
735 fn known_iterates_in_descriptor_order() {
736 let names: Vec<_> = Harness::known().map(Harness::name).collect();
737 assert_eq!(names, ["claude-code", "codex", "opencode"]);
738 }
739
740 #[test]
741 fn labels_match_kebab_case_identifiers() {
742 for name in ["claude-code", "codex", "opencode"] {
743 let harness = Harness::resolve(name).unwrap();
744 assert_eq!(adapter_for(harness).label(), name);
745 }
746 }
747}