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 has_embedded_layer(harness: Harness) -> bool {
335 registry()
336 .iter()
337 .find(|e| e.label == harness.name())
338 .expect("Harness handles originate from the registry")
339 .has_embedded_layer()
340}
341
342pub struct HarnessInfo {
346 pub label: &'static str,
347 pub sources: &'static [(Layer, String)],
348 pub value: &'static serde_json::Value,
349 pub descriptor: &'static HarnessDescriptor,
350}
351
352pub fn harness_info() -> impl Iterator<Item = HarnessInfo> {
355 registry().iter().map(|e| HarnessInfo {
356 label: e.label,
357 sources: &e.sources,
358 value: &e.value,
359 descriptor: e.adapter.descriptor(),
360 })
361}
362
363pub fn default_harness_name() -> &'static str {
366 SESSION_DEFAULT_HARNESS
367 .get()
368 .copied()
369 .unwrap_or(DEFAULT_HARNESS_NAME)
370}
371
372pub fn all_config_dir_names() -> Vec<String> {
377 let mut names: Vec<String> = registry()
378 .iter()
379 .flat_map(|e| e.adapter.config_dir_names())
380 .collect();
381 names.sort_unstable();
382 names.dedup();
383 names
384}
385
386pub fn all_tool_vocabulary() -> &'static ToolVocabulary {
390 static ALL: LazyLock<ToolVocabulary> = LazyLock::new(|| {
391 let mut union = ToolVocabulary::default();
392 for entry in registry().iter() {
393 let vocab = entry.adapter.tool_vocabulary();
394 union.write_tools.extend(vocab.write_tools);
395 union.patch_tools.extend(vocab.patch_tools);
396 union.shell_tools.extend(vocab.shell_tools);
397 union.read_tools.extend(vocab.read_tools);
398 }
399 for list in [
400 &mut union.write_tools,
401 &mut union.patch_tools,
402 &mut union.shell_tools,
403 &mut union.read_tools,
404 ] {
405 list.sort_unstable();
406 list.dedup();
407 }
408 union
409 });
410 &ALL
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 use crate::adapters::descriptor::EMBEDDED_DESCRIPTORS;
418 use crate::adapters::descriptor::layers::{Layer, embedded_sources};
419
420 #[test]
421 fn all_config_dir_names_unions_every_adapter() {
422 assert_eq!(
423 all_config_dir_names(),
424 [".agents", ".claude", ".codex", ".opencode"]
425 );
426 }
427
428 #[test]
429 fn all_tool_vocabulary_unions_every_adapter() {
430 let vocab = all_tool_vocabulary();
431 assert_eq!(
432 vocab.write_tools,
433 [
434 "Edit",
435 "MultiEdit",
436 "NotebookEdit",
437 "Write",
438 "edit",
439 "file_change",
440 "write"
441 ]
442 );
443 assert_eq!(vocab.patch_tools, ["apply_patch"]);
444 assert_eq!(vocab.shell_tools, ["Bash", "bash", "command_execution"]);
445 assert_eq!(
446 vocab.read_tools,
447 ["Glob", "Grep", "Read", "glob", "grep", "read"]
448 );
449 }
450
451 fn src(layer: Layer, path: &str, toml_src: &str) -> DescriptorSource {
452 DescriptorSource {
453 layer,
454 path: path.to_string(),
455 toml_src: toml_src.to_string(),
456 }
457 }
458
459 const USER_GUARD_TOML: &str = r#"
462label = "armed"
463
464[guard]
465hooks_file = ".armed/hooks.json"
466matcher = "Write"
467command_template = '"{exe}" guard-hook --harness armed "{marker}"'
468hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
469verdict_template = '{"decision":"block","reason":"{reason}"}'
470armed_message = "x"
471"#;
472
473 #[test]
474 fn duplicate_embedded_label_errors() {
475 let mut sources = embedded_sources();
476 sources.push(sources[0].clone());
477 let err = build_registry(sources).unwrap_err().to_string();
478 assert!(err.contains("duplicate harness label"), "{err}");
479 assert!(err.contains("claude-code"), "names the label: {err}");
480 assert!(
481 err.contains("harnesses/claude-code.toml"),
482 "names the colliding source files: {err}"
483 );
484 }
485
486 #[test]
487 fn registry_entries_record_embedded_provenance() {
488 let built = build_registry(embedded_sources()).unwrap();
489 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
490 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
491 for entry in &built.entries {
492 assert_eq!(entry.sources.len(), 1, "one contributing file per built-in");
493 assert_eq!(entry.sources[0].0, Layer::Embedded);
494 assert!(
495 entry.sources[0].1.contains(entry.label),
496 "source path names the harness: {}",
497 entry.sources[0].1
498 );
499 }
500 }
501
502 #[test]
503 fn project_layer_overrides_a_single_field_of_a_builtin() {
504 let mut sources = embedded_sources();
505 sources.push(src(
506 Layer::ProjectLocal,
507 ".eval-magic/harnesses/claude-code.toml",
508 "label = \"claude-code\"\n\n[model]\nflag = \"--model-x\"\n",
509 ));
510 let built = build_registry(sources).unwrap();
511 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
512 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
513 let entry = built
514 .entries
515 .iter()
516 .find(|e| e.label == "claude-code")
517 .unwrap();
518 assert_eq!(entry.adapter.cli_model_flag(), Some("--model-x".into()));
521 assert!(entry.adapter.run_capabilities().supports_guard);
522 assert_eq!(
523 entry.sources,
524 vec![
525 (Layer::Embedded, "harnesses/claude-code.toml".to_string()),
526 (
527 Layer::ProjectLocal,
528 ".eval-magic/harnesses/claude-code.toml".to_string()
529 ),
530 ]
531 );
532 }
533
534 #[test]
535 fn new_label_in_a_user_layer_registers_a_new_harness() {
536 let mut sources = embedded_sources();
537 sources.push(src(
538 Layer::ProjectLocal,
539 ".eval-magic/harnesses/cool.toml",
540 "label = \"cool-custom-harness\"\n",
541 ));
542 let built = build_registry(sources).unwrap();
543 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
544 let entry = built
545 .entries
546 .iter()
547 .find(|e| e.label == "cool-custom-harness")
548 .expect("new harness registered");
549 assert_eq!(entry.sources[0].0, Layer::ProjectLocal);
550 assert!(entry.adapter.skills_dir(Path::new("/r")).is_none());
551 }
552
553 #[test]
554 fn discovered_file_declaring_a_guard_warns_and_is_skipped() {
555 let mut sources = embedded_sources();
556 sources.push(src(
557 Layer::ProjectLocal,
558 ".eval-magic/harnesses/armed.toml",
559 USER_GUARD_TOML,
560 ));
561 let built = build_registry(sources).unwrap();
562 assert_eq!(
563 built.entries.len(),
564 EMBEDDED_DESCRIPTORS.len(),
565 "file skipped"
566 );
567 assert_eq!(built.warnings.len(), 1);
568 assert!(
569 built.warnings[0].contains("may not declare [guard]"),
570 "{}",
571 built.warnings[0]
572 );
573 }
574
575 #[test]
576 fn discovered_overlay_breaking_invariants_warns_and_keeps_the_base() {
577 let mut sources = embedded_sources();
578 sources.push(src(
581 Layer::ProjectLocal,
582 ".eval-magic/harnesses/claude-code.toml",
583 "label = \"claude-code\"\nconfig_dirs = [\".other\"]\n",
584 ));
585 let built = build_registry(sources).unwrap();
586 assert_eq!(built.warnings.len(), 1);
587 assert!(
588 built.warnings[0].contains(".eval-magic/harnesses/claude-code.toml"),
589 "names the offending file: {}",
590 built.warnings[0]
591 );
592 let entry = built
593 .entries
594 .iter()
595 .find(|e| e.label == "claude-code")
596 .unwrap();
597 assert_eq!(
598 entry.adapter.config_dir_names(),
599 vec![".claude".to_string()],
600 "embedded base survives the dropped overlay"
601 );
602 assert_eq!(
603 entry.sources.len(),
604 1,
605 "the bad overlay records no provenance"
606 );
607 }
608
609 #[test]
610 fn same_label_twice_in_one_discovered_layer_warns_and_skips_the_second() {
611 let mut sources = embedded_sources();
612 sources.push(src(
613 Layer::ProjectLocal,
614 "a.toml",
615 "label = \"claude-code\"\n\n[model]\nflag = \"--from-a\"\n",
616 ));
617 sources.push(src(
618 Layer::ProjectLocal,
619 "b.toml",
620 "label = \"claude-code\"\n\n[model]\nflag = \"--from-b\"\n",
621 ));
622 let built = build_registry(sources).unwrap();
623 assert_eq!(built.warnings.len(), 1);
624 assert!(
625 built.warnings[0].contains("a.toml"),
626 "{}",
627 built.warnings[0]
628 );
629 assert!(
630 built.warnings[0].contains("b.toml"),
631 "{}",
632 built.warnings[0]
633 );
634 let entry = built
635 .entries
636 .iter()
637 .find(|e| e.label == "claude-code")
638 .unwrap();
639 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-a".into()));
640 }
641
642 #[test]
643 fn harness_file_failures_are_fatal() {
644 let mut sources = embedded_sources();
645 sources.push(src(Layer::HarnessFile, "one-off.toml", "label = "));
646 let err = build_registry(sources).unwrap_err().to_string();
647 assert!(err.contains("one-off.toml"), "{err}");
648 }
649
650 #[test]
651 fn harness_file_guard_rejection_is_fatal() {
652 let mut sources = embedded_sources();
653 sources.push(src(Layer::HarnessFile, "one-off.toml", USER_GUARD_TOML));
654 let err = build_registry(sources).unwrap_err().to_string();
655 assert!(err.contains("may not declare [guard]"), "{err}");
656 }
657
658 #[test]
659 fn harness_file_overlay_merges_on_top_of_discovered_layers() {
660 let mut sources = embedded_sources();
661 sources.push(src(
662 Layer::ProjectLocal,
663 "p.toml",
664 "label = \"claude-code\"\n\n[model]\nflag = \"--from-project\"\n",
665 ));
666 sources.push(src(
667 Layer::HarnessFile,
668 "one-off.toml",
669 "label = \"claude-code\"\n\n[model]\nflag = \"--from-file\"\n",
670 ));
671 let built = build_registry(sources).unwrap();
672 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
673 let entry = built
674 .entries
675 .iter()
676 .find(|e| e.label == "claude-code")
677 .unwrap();
678 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-file".into()));
679 assert_eq!(entry.sources.len(), 3);
680 }
681
682 #[test]
683 fn embedded_layer_provenance_distinguishes_built_ins_from_user_only_harnesses() {
684 let mut sources = embedded_sources();
685 sources.push(src(
686 Layer::ProjectLocal,
687 ".eval-magic/harnesses/cool.toml",
688 "label = \"cool\"\n",
689 ));
690 let built = build_registry(sources).unwrap();
691 let entry = |label: &str| built.entries.iter().find(|e| e.label == label).unwrap();
692 assert!(
693 entry("claude-code").has_embedded_layer(),
694 "built-ins carry their embedded source"
695 );
696 assert!(
697 !entry("cool").has_embedded_layer(),
698 "a user-only harness has no embedded layer"
699 );
700 }
701
702 #[test]
703 fn resolve_unknown_name_lists_known_harnesses() {
704 let err = Harness::resolve("nonexistent").unwrap_err().to_string();
705 assert!(err.contains("unknown harness 'nonexistent'"), "{err}");
706 for name in ["claude-code", "codex", "opencode"] {
707 assert!(err.contains(name), "error must name {name}: {err}");
708 }
709 }
710
711 #[test]
712 fn resolve_round_trips_every_registry_entry() {
713 for harness in Harness::known() {
714 assert_eq!(Harness::resolve(harness.name()).unwrap(), harness);
715 }
716 }
717
718 #[test]
719 fn default_harness_is_claude_code() {
720 assert_eq!(DEFAULT_HARNESS_NAME, "claude-code");
721 assert_eq!(Harness::default().name(), DEFAULT_HARNESS_NAME);
722 }
723
724 #[test]
725 fn known_iterates_in_descriptor_order() {
726 let names: Vec<_> = Harness::known().map(Harness::name).collect();
727 assert_eq!(names, ["claude-code", "codex", "opencode"]);
728 }
729
730 #[test]
731 fn labels_match_kebab_case_identifiers() {
732 for name in ["claude-code", "codex", "opencode"] {
733 let harness = Harness::resolve(name).unwrap();
734 assert_eq!(adapter_for(harness).label(), name);
735 }
736 }
737}