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 ["Edit", "MultiEdit", "NotebookEdit", "Write", "file_change"]
434 );
435 assert_eq!(vocab.patch_tools, ["apply_patch"]);
436 assert_eq!(vocab.shell_tools, ["Bash", "command_execution"]);
437 assert_eq!(vocab.read_tools, ["Glob", "Grep", "Read"]);
438 }
439
440 fn src(layer: Layer, path: &str, toml_src: &str) -> DescriptorSource {
441 DescriptorSource {
442 layer,
443 path: path.to_string(),
444 toml_src: toml_src.to_string(),
445 }
446 }
447
448 const USER_GUARD_TOML: &str = r#"
451label = "armed"
452
453[guard]
454hooks_file = ".armed/hooks.json"
455matcher = "Write"
456command_template = '"{exe}" guard-hook --harness armed "{marker}"'
457hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
458verdict_template = '{"decision":"block","reason":"{reason}"}'
459armed_message = "x"
460"#;
461
462 #[test]
463 fn duplicate_embedded_label_errors() {
464 let mut sources = embedded_sources();
465 sources.push(sources[0].clone());
466 let err = build_registry(sources).unwrap_err().to_string();
467 assert!(err.contains("duplicate harness label"), "{err}");
468 assert!(err.contains("claude-code"), "names the label: {err}");
469 assert!(
470 err.contains("harnesses/claude-code.toml"),
471 "names the colliding source files: {err}"
472 );
473 }
474
475 #[test]
476 fn registry_entries_record_embedded_provenance() {
477 let built = build_registry(embedded_sources()).unwrap();
478 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
479 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
480 for entry in &built.entries {
481 assert_eq!(entry.sources.len(), 1, "one contributing file per built-in");
482 assert_eq!(entry.sources[0].0, Layer::Embedded);
483 assert!(
484 entry.sources[0].1.contains(entry.label),
485 "source path names the harness: {}",
486 entry.sources[0].1
487 );
488 }
489 }
490
491 #[test]
492 fn project_layer_overrides_a_single_field_of_a_builtin() {
493 let mut sources = embedded_sources();
494 sources.push(src(
495 Layer::ProjectLocal,
496 ".eval-magic/harnesses/claude-code.toml",
497 "label = \"claude-code\"\n\n[model]\nflag = \"--model-x\"\n",
498 ));
499 let built = build_registry(sources).unwrap();
500 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
501 assert_eq!(built.entries.len(), EMBEDDED_DESCRIPTORS.len());
502 let entry = built
503 .entries
504 .iter()
505 .find(|e| e.label == "claude-code")
506 .unwrap();
507 assert_eq!(entry.adapter.cli_model_flag(), Some("--model-x".into()));
510 assert!(entry.adapter.run_capabilities().supports_guard);
511 assert_eq!(
512 entry.sources,
513 vec![
514 (Layer::Embedded, "harnesses/claude-code.toml".to_string()),
515 (
516 Layer::ProjectLocal,
517 ".eval-magic/harnesses/claude-code.toml".to_string()
518 ),
519 ]
520 );
521 }
522
523 #[test]
524 fn new_label_in_a_user_layer_registers_a_new_harness() {
525 let mut sources = embedded_sources();
526 sources.push(src(
527 Layer::ProjectLocal,
528 ".eval-magic/harnesses/cool.toml",
529 "label = \"cool-custom-harness\"\n",
530 ));
531 let built = build_registry(sources).unwrap();
532 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
533 let entry = built
534 .entries
535 .iter()
536 .find(|e| e.label == "cool-custom-harness")
537 .expect("new harness registered");
538 assert_eq!(entry.sources[0].0, Layer::ProjectLocal);
539 assert!(entry.adapter.skills_dir(Path::new("/r")).is_none());
540 }
541
542 #[test]
543 fn discovered_file_declaring_a_guard_warns_and_is_skipped() {
544 let mut sources = embedded_sources();
545 sources.push(src(
546 Layer::ProjectLocal,
547 ".eval-magic/harnesses/armed.toml",
548 USER_GUARD_TOML,
549 ));
550 let built = build_registry(sources).unwrap();
551 assert_eq!(
552 built.entries.len(),
553 EMBEDDED_DESCRIPTORS.len(),
554 "file skipped"
555 );
556 assert_eq!(built.warnings.len(), 1);
557 assert!(
558 built.warnings[0].contains("may not declare [guard]"),
559 "{}",
560 built.warnings[0]
561 );
562 }
563
564 #[test]
565 fn discovered_overlay_breaking_invariants_warns_and_keeps_the_base() {
566 let mut sources = embedded_sources();
567 sources.push(src(
570 Layer::ProjectLocal,
571 ".eval-magic/harnesses/claude-code.toml",
572 "label = \"claude-code\"\nconfig_dirs = [\".other\"]\n",
573 ));
574 let built = build_registry(sources).unwrap();
575 assert_eq!(built.warnings.len(), 1);
576 assert!(
577 built.warnings[0].contains(".eval-magic/harnesses/claude-code.toml"),
578 "names the offending file: {}",
579 built.warnings[0]
580 );
581 let entry = built
582 .entries
583 .iter()
584 .find(|e| e.label == "claude-code")
585 .unwrap();
586 assert_eq!(
587 entry.adapter.config_dir_names(),
588 vec![".claude".to_string()],
589 "embedded base survives the dropped overlay"
590 );
591 assert_eq!(
592 entry.sources.len(),
593 1,
594 "the bad overlay records no provenance"
595 );
596 }
597
598 #[test]
599 fn same_label_twice_in_one_discovered_layer_warns_and_skips_the_second() {
600 let mut sources = embedded_sources();
601 sources.push(src(
602 Layer::ProjectLocal,
603 "a.toml",
604 "label = \"claude-code\"\n\n[model]\nflag = \"--from-a\"\n",
605 ));
606 sources.push(src(
607 Layer::ProjectLocal,
608 "b.toml",
609 "label = \"claude-code\"\n\n[model]\nflag = \"--from-b\"\n",
610 ));
611 let built = build_registry(sources).unwrap();
612 assert_eq!(built.warnings.len(), 1);
613 assert!(
614 built.warnings[0].contains("a.toml"),
615 "{}",
616 built.warnings[0]
617 );
618 assert!(
619 built.warnings[0].contains("b.toml"),
620 "{}",
621 built.warnings[0]
622 );
623 let entry = built
624 .entries
625 .iter()
626 .find(|e| e.label == "claude-code")
627 .unwrap();
628 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-a".into()));
629 }
630
631 #[test]
632 fn harness_file_failures_are_fatal() {
633 let mut sources = embedded_sources();
634 sources.push(src(Layer::HarnessFile, "one-off.toml", "label = "));
635 let err = build_registry(sources).unwrap_err().to_string();
636 assert!(err.contains("one-off.toml"), "{err}");
637 }
638
639 #[test]
640 fn harness_file_guard_rejection_is_fatal() {
641 let mut sources = embedded_sources();
642 sources.push(src(Layer::HarnessFile, "one-off.toml", USER_GUARD_TOML));
643 let err = build_registry(sources).unwrap_err().to_string();
644 assert!(err.contains("may not declare [guard]"), "{err}");
645 }
646
647 #[test]
648 fn harness_file_overlay_merges_on_top_of_discovered_layers() {
649 let mut sources = embedded_sources();
650 sources.push(src(
651 Layer::ProjectLocal,
652 "p.toml",
653 "label = \"claude-code\"\n\n[model]\nflag = \"--from-project\"\n",
654 ));
655 sources.push(src(
656 Layer::HarnessFile,
657 "one-off.toml",
658 "label = \"claude-code\"\n\n[model]\nflag = \"--from-file\"\n",
659 ));
660 let built = build_registry(sources).unwrap();
661 assert!(built.warnings.is_empty(), "{:?}", built.warnings);
662 let entry = built
663 .entries
664 .iter()
665 .find(|e| e.label == "claude-code")
666 .unwrap();
667 assert_eq!(entry.adapter.cli_model_flag(), Some("--from-file".into()));
668 assert_eq!(entry.sources.len(), 3);
669 }
670
671 #[test]
672 fn embedded_layer_provenance_distinguishes_built_ins_from_user_only_harnesses() {
673 let mut sources = embedded_sources();
674 sources.push(src(
675 Layer::ProjectLocal,
676 ".eval-magic/harnesses/cool.toml",
677 "label = \"cool\"\n",
678 ));
679 let built = build_registry(sources).unwrap();
680 let entry = |label: &str| built.entries.iter().find(|e| e.label == label).unwrap();
681 assert!(
682 entry("claude-code").has_embedded_layer(),
683 "built-ins carry their embedded source"
684 );
685 assert!(
686 !entry("cool").has_embedded_layer(),
687 "a user-only harness has no embedded layer"
688 );
689 }
690
691 #[test]
692 fn resolve_unknown_name_lists_known_harnesses() {
693 let err = Harness::resolve("nonexistent").unwrap_err().to_string();
694 assert!(err.contains("unknown harness 'nonexistent'"), "{err}");
695 for name in ["claude-code", "codex", "opencode"] {
696 assert!(err.contains(name), "error must name {name}: {err}");
697 }
698 }
699
700 #[test]
701 fn resolve_round_trips_every_registry_entry() {
702 for harness in Harness::known() {
703 assert_eq!(Harness::resolve(harness.name()).unwrap(), harness);
704 }
705 }
706
707 #[test]
708 fn default_harness_is_claude_code() {
709 assert_eq!(DEFAULT_HARNESS_NAME, "claude-code");
710 assert_eq!(Harness::default().name(), DEFAULT_HARNESS_NAME);
711 }
712
713 #[test]
714 fn known_iterates_in_descriptor_order() {
715 let names: Vec<_> = Harness::known().map(Harness::name).collect();
716 assert_eq!(names, ["claude-code", "codex", "opencode"]);
717 }
718
719 #[test]
720 fn labels_match_kebab_case_identifiers() {
721 for name in ["claude-code", "codex", "opencode"] {
722 let harness = Harness::resolve(name).unwrap();
723 assert_eq!(adapter_for(harness).label(), name);
724 }
725 }
726}