Skip to main content

eval_magic/adapters/
descriptor.rs

1//! Harness descriptor files: the data half of a harness adapter.
2//!
3//! A descriptor is a TOML file carrying every declarative value a harness
4//! adapter exposes (label, dirs, capability booleans, phrases, templates,
5//! banner prose, the write-guard data block) plus references to *named
6//! capabilities* — the code-backed features in [`super::capabilities`]
7//! (transcript parsers, slug generation, shadow preflight).
8//!
9//! Loading is schema-gated: the TOML transcodes to JSON and must satisfy the
10//! bundled `schema/harness-descriptor.schema.json`, then the cross-field
11//! invariants in [`validate_descriptor`] — the load-time form of the old
12//! cross-harness adapter tests, so user-supplied descriptor files inherit the
13//! same checks.
14
15use 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::transcript::TranscriptSummary;
24
25pub mod layers;
26mod validation;
27
28/// The three built-in harness descriptors, embedded like the schemas: a
29/// `(source path, TOML text)` pair per harness, in registry order.
30pub const EMBEDDED_DESCRIPTORS: [(&str, &str); 3] = [
31    (
32        "harnesses/claude-code.toml",
33        include_str!("../../harnesses/claude-code.toml"),
34    ),
35    (
36        "harnesses/codex.toml",
37        include_str!("../../harnesses/codex.toml"),
38    ),
39    (
40        "harnesses/opencode.toml",
41        include_str!("../../harnesses/opencode.toml"),
42    ),
43];
44
45/// A parsed, schema-checked, invariant-checked harness descriptor.
46///
47/// Field docs live in `schema/harness-descriptor.schema.json` (the schema gate
48/// and this struct are kept honest against each other by
49/// [`validate_against_schema`]'s deserialize step).
50#[derive(Debug, Clone, Deserialize, Serialize)]
51pub struct HarnessDescriptor {
52    pub label: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub skills_dir: Option<String>,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub config_dirs: Vec<String>,
57    #[serde(default, skip_serializing_if = "RunSection::is_default")]
58    pub run: RunSection,
59    #[serde(default, skip_serializing_if = "ToolsSection::is_empty")]
60    pub tools: ToolsSection,
61    #[serde(default, skip_serializing_if = "StagingSection::is_unconfigured")]
62    pub staging: StagingSection,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub skills_block: Option<SkillsBlockSection>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub transcript: Option<TranscriptSection>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub model: Option<ModelSection>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub guard: Option<GuardSection>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub shadow: Option<ShadowSection>,
73    #[serde(default, skip_serializing_if = "DispatchSection::is_empty")]
74    pub dispatch: DispatchSection,
75}
76
77/// Run-option capabilities. The `Default` mirrors the baseline every harness
78/// gets without opting in: no guard, bootstrap/stage-name allowed unstaged.
79#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
80pub struct RunSection {
81    #[serde(default)]
82    pub supports_guard: bool,
83    #[serde(default = "default_true")]
84    pub supports_bootstrap_with_no_stage: bool,
85    #[serde(default = "default_true")]
86    pub supports_stage_name_with_no_stage: bool,
87}
88
89impl RunSection {
90    /// True when every capability sits at its baseline default — the signal
91    /// `harness show` uses to omit the section from authorable output.
92    pub fn is_default(&self) -> bool {
93        *self == RunSection::default()
94    }
95}
96
97impl Default for RunSection {
98    fn default() -> Self {
99        RunSection {
100            supports_guard: false,
101            supports_bootstrap_with_no_stage: true,
102            supports_stage_name_with_no_stage: true,
103        }
104    }
105}
106
107#[derive(Debug, Clone, Default, Deserialize, Serialize)]
108pub struct ToolsSection {
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub write: Vec<String>,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub patch: Vec<String>,
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub shell: Vec<String>,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub read: Vec<String>,
117}
118
119impl ToolsSection {
120    /// True when no role declares any tool names.
121    pub fn is_empty(&self) -> bool {
122        self.write.is_empty()
123            && self.patch.is_empty()
124            && self.shell.is_empty()
125            && self.read.is_empty()
126    }
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize)]
130pub struct StagingSection {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub slug_template: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub slug_capability: Option<SlugCapability>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub stage_name_pattern: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub stage_name_max_len: Option<usize>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub stage_name_invalid_message: Option<String>,
141    #[serde(default, skip_serializing_if = "is_false")]
142    pub rewrites_frontmatter_name: bool,
143    #[serde(default, skip_serializing_if = "is_false")]
144    pub advertises_staged_slug_name: bool,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub surface_phrase: Option<String>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub unresolved_phrase: Option<String>,
149}
150
151impl StagingSection {
152    /// True when any field departs from the defaults — the signal that the
153    /// descriptor actually configures native staging.
154    pub fn is_configured(&self) -> bool {
155        !self.is_unconfigured()
156    }
157
158    fn is_unconfigured(&self) -> bool {
159        self.slug_template.is_none()
160            && self.slug_capability.is_none()
161            && self.stage_name_pattern.is_none()
162            && self.stage_name_max_len.is_none()
163            && self.stage_name_invalid_message.is_none()
164            && !self.rewrites_frontmatter_name
165            && !self.advertises_staged_slug_name
166            && self.surface_phrase.is_none()
167            && self.unresolved_phrase.is_none()
168    }
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
172pub struct SkillsBlockSection {
173    pub header: String,
174    pub item: String,
175    #[serde(default, skip_serializing_if = "String::is_empty")]
176    pub footer: String,
177}
178
179#[derive(Debug, Clone, Deserialize, Serialize)]
180pub struct TranscriptSection {
181    pub events_filename: String,
182    /// Named code parser — for streams that need stitching (keyed joins,
183    /// content coercion). Exactly one of `parser`/`extract` is declared.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub parser: Option<TranscriptParser>,
186    /// Declarative extractor for flat event streams.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub extract: Option<ExtractSpec>,
189    #[serde(default = "default_true", skip_serializing_if = "is_true")]
190    pub surfaces_skill_invocation: bool,
191}
192
193impl TranscriptSection {
194    /// Parse the events file into ordered tool invocations, through whichever
195    /// tier the descriptor declares.
196    pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
197        match (&self.parser, &self.extract) {
198            (Some(parser), _) => parser.parse(path),
199            (None, Some(extract)) => super::extract::parse(extract, path),
200            // Validation guarantees one tier is declared; fail like a
201            // parser-less harness if a section slips through unchecked.
202            (None, None) => Err(unwired_error()),
203        }
204    }
205
206    /// Parse the events file into a full [`TranscriptSummary`].
207    pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
208        match (&self.parser, &self.extract) {
209            (Some(parser), _) => parser.parse_full(path),
210            (None, Some(extract)) => super::extract::parse_full(extract, path),
211            (None, None) => Err(unwired_error()),
212        }
213    }
214}
215
216fn unwired_error() -> std::io::Error {
217    std::io::Error::new(
218        std::io::ErrorKind::Unsupported,
219        "[transcript] declares neither a parser nor an extract block",
220    )
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct ModelSection {
225    pub flag: String,
226}
227
228/// The write-guard data block: everything the generic guard engine
229/// ([`super::guard`]) needs to arm a hook and render a deny verdict. Embedded
230/// built-in descriptors only — the guard fails open, so
231/// [`layers::check_user_layer_restrictions`] bars user layers from declaring
232/// it.
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct GuardSection {
235    /// Hook-config file the entry is merged into, `/`-separated relative to
236    /// the staged env root (e.g. `.claude/settings.local.json`).
237    pub hooks_file: String,
238    /// Tool-name matcher the hook registers for; also the source of the
239    /// matcher⊆vocabulary invariant.
240    pub matcher: String,
241    /// Shell command the hook runs, with `{exe}` and `{marker}` placeholders.
242    pub command_template: String,
243    /// JSON template of the hook entry appended to the hook config's
244    /// `hooks.PreToolUse` array, with `{matcher}` and `{command}` placeholders
245    /// in its string values. Authored key order is serialized verbatim.
246    pub hook_entry: String,
247    /// JSON template of the deny verdict printed on stdout, with a `{reason}`
248    /// placeholder. Authored key order is the harness's on-disk contract.
249    pub verdict_template: String,
250    /// Banner printed after `--guard` arms the hook.
251    pub armed_message: String,
252}
253
254#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct ShadowSection {
256    pub preflight: ShadowPreflight,
257}
258
259#[derive(Debug, Clone, Default, Deserialize, Serialize)]
260pub struct DispatchSection {
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub capture_prefix: Option<String>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub guard_args: Option<String>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub model_note: Option<String>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub next_steps_template: Option<String>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub exec_template: Option<String>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub parallel_command_template: Option<String>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub judge_command_template: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub manifest_template: Option<String>,
277}
278
279impl DispatchSection {
280    /// True when no dispatch field is set.
281    pub fn is_empty(&self) -> bool {
282        self.capture_prefix.is_none()
283            && self.guard_args.is_none()
284            && self.model_note.is_none()
285            && self.next_steps_template.is_none()
286            && self.exec_template.is_none()
287            && self.parallel_command_template.is_none()
288            && self.judge_command_template.is_none()
289            && self.manifest_template.is_none()
290    }
291}
292
293fn default_true() -> bool {
294    true
295}
296
297/// `skip_serializing_if` helpers for boolean fields at their defaults.
298fn is_false(value: &bool) -> bool {
299    !*value
300}
301
302fn is_true(value: &bool) -> bool {
303    *value
304}
305
306/// A descriptor that failed to load. Every variant carries the descriptor's
307/// source path so the message is actionable on its own.
308#[derive(Debug, thiserror::Error)]
309pub enum DescriptorError {
310    #[error("{path}: invalid TOML: {message}")]
311    Toml { path: String, message: String },
312    #[error(transparent)]
313    Validation(#[from] ValidationError),
314    #[error("{path}: {message}")]
315    Invariant { path: String, message: String },
316}
317
318/// Parse, schema-check, and invariant-check one descriptor. `source` names the
319/// descriptor in error messages (its file path).
320pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
321    finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
322}
323
324/// Parse one descriptor file's TOML into a JSON value and schema-check it.
325/// This is the per-file gate every layered source passes individually — the
326/// schema requires only `label`, so partial override files validate here too,
327/// while unknown fields and bad capability names are still rejected with the
328/// file's own path in the message.
329pub fn parse_descriptor_value(
330    toml_src: &str,
331    source: &str,
332) -> Result<serde_json::Value, DescriptorError> {
333    let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
334        path: source.to_string(),
335        message: e.to_string(),
336    })?;
337    let _: serde_json::Value =
338        validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
339    Ok(value)
340}
341
342/// Merge `overlay` onto `base`, field by field: objects merge recursively per
343/// key; scalars, arrays, and nulls replace wholesale. This is the layering
344/// rule — a later descriptor file overrides individual fields of an earlier
345/// one, never whole-file shadowing.
346pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
347    match (base, overlay) {
348        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
349            for (key, value) in overlay {
350                match base.get_mut(&key) {
351                    Some(slot) if slot.is_object() && value.is_object() => {
352                        merge_descriptor_value(slot, value);
353                    }
354                    _ => {
355                        base.insert(key, value);
356                    }
357                }
358            }
359        }
360        (base, overlay) => *base = overlay,
361    }
362}
363
364/// Deserialize and invariant-check a (possibly merged) descriptor value.
365/// `provenance` names every contributing file (e.g. `base.toml (built-in) +
366/// overlay.toml (project)`) so a post-merge violation is traceable.
367pub fn finalize_descriptor(
368    value: &serde_json::Value,
369    provenance: &str,
370) -> Result<HarnessDescriptor, DescriptorError> {
371    let descriptor: HarnessDescriptor =
372        validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
373    validation::validate_descriptor(&descriptor, provenance)?;
374    Ok(descriptor)
375}
376
377/// Substitute single-brace `{token}` placeholders in `template`.
378///
379/// Single left-to-right pass over the original template: substituted values
380/// are never re-scanned, and unknown or unterminated tokens (shell text like
381/// `${JOBS:-4}` or `-I{}`) pass through verbatim.
382pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
383    let mut out = String::with_capacity(template.len());
384    let mut rest = template;
385    while let Some(start) = rest.find('{') {
386        out.push_str(&rest[..start]);
387        let after = &rest[start + 1..];
388        let Some(end) = after.find('}') else {
389            out.push('{');
390            rest = after;
391            continue;
392        };
393        match vars.iter().find(|(k, _)| *k == &after[..end]) {
394            Some((_, value)) => {
395                out.push_str(value);
396                rest = &after[end + 1..];
397            }
398            None => {
399                out.push('{');
400                rest = after;
401            }
402        }
403    }
404    out.push_str(rest);
405    out
406}
407
408/// The staged-slug shape used when a descriptor declares neither a template
409/// nor a capability.
410pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
411
412/// Render the staged slug for one `(iteration, condition, skill)` cell from
413/// the descriptor's slug capability or template (or the default template).
414pub(crate) fn render_staged_slug(
415    staging: &StagingSection,
416    prefix: &str,
417    iteration: u32,
418    condition: &str,
419    skill_name: &str,
420) -> String {
421    if let Some(capability) = staging.slug_capability {
422        return capability.staged_slug(prefix, iteration, condition, skill_name);
423    }
424    let iteration = iteration.to_string();
425    subst(
426        staging
427            .slug_template
428            .as_deref()
429            .unwrap_or(DEFAULT_SLUG_TEMPLATE),
430        &[
431            ("prefix", prefix),
432            ("iteration", &iteration),
433            ("condition", condition),
434            ("skill_name", skill_name),
435        ],
436    )
437}
438
439/// Check `name` against the descriptor's declarative stage-name rules,
440/// returning the rejection message on violation.
441pub(crate) fn stage_name_error(
442    staging: &StagingSection,
443    regex: Option<&Regex>,
444    name: &str,
445) -> Option<String> {
446    let len_ok = staging
447        .stage_name_max_len
448        .is_none_or(|max| name.len() <= max);
449    let pattern_ok = regex.is_none_or(|r| r.is_match(name));
450    if len_ok && pattern_ok {
451        return None;
452    }
453    Some(match &staging.stage_name_invalid_message {
454        Some(message) => subst(message, &[("name", name)]),
455        None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
456    })
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
464        load_descriptor(toml_src, "test.toml")
465    }
466
467    fn err_of(toml_src: &str) -> String {
468        load(toml_src)
469            .expect_err("descriptor should be rejected")
470            .to_string()
471    }
472
473    const MINIMAL: &str = r#"
474label = "demo"
475skills_dir = ".demo/skills"
476config_dirs = [".demo"]
477"#;
478
479    const GUARDED: &str = r#"
480label = "demo"
481skills_dir = ".demo/skills"
482config_dirs = [".demo"]
483
484[run]
485supports_guard = true
486
487[tools]
488write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
489shell = ["Bash"]
490
491[guard]
492hooks_file = ".demo/hooks.json"
493matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
494command_template = '"{exe}" guard-hook --harness demo "{marker}"'
495hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
496verdict_template = '{"decision":"block","reason":"{reason}"}'
497armed_message = "guard armed"
498"#;
499
500    /// A descriptor whose transcript ingest is the declarative extract tier.
501    const EXTRACTED: &str = r#"
502label = "demo"
503skills_dir = ".demo/skills"
504config_dirs = [".demo"]
505
506[tools]
507write = ["file_change"]
508shell = ["command_execution"]
509
510[transcript]
511events_filename = "demo-events.jsonl"
512
513[transcript.extract.tools]
514where = { type = "item.completed" }
515item = "item"
516name_field = "type"
517skip_names = ["agent_message"]
518args_omit = ["id", "type", "output"]
519result_coalesce = ["output"]
520
521[transcript.extract.final_text]
522where = { type = "item.completed", "item.type" = "agent_message" }
523field = "item.text"
524
525[transcript.extract.tokens]
526where = { type = "turn.completed" }
527sum = ["usage.input_tokens", "usage.output_tokens"]
528
529[transcript.extract.duration]
530timestamp_spread = "timestamp"
531"#;
532
533    #[test]
534    fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
535        let d = load(EXTRACTED).unwrap();
536        let transcript = d.transcript.as_ref().expect("transcript section loads");
537        assert!(transcript.parser.is_none());
538        assert!(transcript.extract.is_some());
539
540        // `harness show` re-serializes resolved descriptors to TOML; the
541        // extract tier must survive the round trip.
542        let shown = toml::to_string(&d).expect("descriptor re-serializes");
543        let reloaded = load(&shown).unwrap();
544        let extract = reloaded.transcript.unwrap().extract.unwrap();
545        assert_eq!(
546            extract.tokens.unwrap().sum,
547            vec!["usage.input_tokens", "usage.output_tokens"]
548        );
549        assert_eq!(
550            extract
551                .tools
552                .unwrap()
553                .r#where
554                .get("type")
555                .map(String::as_str),
556            Some("item.completed")
557        );
558    }
559
560    #[test]
561    fn transcript_section_dispatches_parse_to_the_extract_engine() {
562        let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
563        let dir = tempfile::TempDir::new().unwrap();
564        let path = dir.path().join("demo-events.jsonl");
565        std::fs::write(
566            &path,
567            concat!(
568                r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
569                "\n",
570                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
571                "\n",
572            ),
573        )
574        .unwrap();
575
576        let invocations = transcript.parse(&path).unwrap();
577        assert_eq!(invocations.len(), 1);
578        assert_eq!(invocations[0].name, "command_execution");
579
580        let full = transcript.parse_full(&path).unwrap();
581        assert_eq!(full.final_text, Some("Done.".into()));
582        assert_eq!(full.tool_invocations.len(), 1);
583    }
584
585    #[test]
586    fn minimal_descriptor_loads() {
587        let d = load(MINIMAL).unwrap();
588        assert_eq!(d.label, "demo");
589        assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
590        assert_eq!(d.config_dirs, vec![".demo".to_string()]);
591        // Section defaults match the trait defaults.
592        assert!(!d.run.supports_guard);
593        assert!(d.run.supports_bootstrap_with_no_stage);
594        assert!(d.run.supports_stage_name_with_no_stage);
595        assert!(!d.staging.rewrites_frontmatter_name);
596        assert!(d.guard.is_none());
597        assert!(d.transcript.is_none());
598    }
599
600    #[test]
601    fn guarded_descriptor_loads() {
602        let d = load(GUARDED).unwrap();
603        assert!(d.run.supports_guard);
604        let guard = d.guard.expect("guard section loads");
605        assert_eq!(guard.hooks_file, ".demo/hooks.json");
606        assert_eq!(guard.matcher, "Write|Edit|MultiEdit|NotebookEdit|Bash");
607        assert_eq!(
608            guard.command_template,
609            r#""{exe}" guard-hook --harness demo "{marker}""#
610        );
611        assert_eq!(guard.armed_message, "guard armed");
612    }
613
614    #[test]
615    fn label_only_descriptor_loads_as_baseline() {
616        let d = load("label = \"demo\"\n").unwrap();
617        assert_eq!(d.label, "demo");
618        assert!(d.skills_dir.is_none(), "no skills dir declared");
619        assert!(d.config_dirs.is_empty());
620        assert!(!d.run.supports_guard);
621    }
622
623    #[test]
624    fn rejects_staging_without_skills_dir() {
625        let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
626        assert!(err.contains("staging"), "{err}");
627        assert!(err.contains("skills_dir"), "{err}");
628    }
629
630    #[test]
631    fn rejects_guard_without_skills_dir() {
632        let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
633        let err = err_of(toml);
634        assert!(err.contains("guard"), "{err}");
635        assert!(err.contains("skills_dir"), "{err}");
636    }
637
638    #[test]
639    fn embedded_descriptors_load_and_validate() {
640        for (source, toml_src) in EMBEDDED_DESCRIPTORS {
641            let d = load_descriptor(toml_src, source)
642                .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
643            assert!(!d.label.is_empty());
644        }
645    }
646
647    #[test]
648    fn rejects_invalid_toml_syntax() {
649        let err = err_of("label = ");
650        assert!(err.contains("test.toml"), "{err}");
651        assert!(err.contains("invalid TOML"), "{err}");
652    }
653
654    #[test]
655    fn rejects_unknown_top_level_field() {
656        let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
657        assert!(err.contains("harness-descriptor schema"), "{err}");
658    }
659
660    #[test]
661    fn rejects_non_kebab_case_label() {
662        let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
663        assert!(err.contains("harness-descriptor schema"), "{err}");
664    }
665
666    /// Migration canary: the pre-#137 named-engine shape must fail the schema
667    /// gate, so a stale `engine = "..."` line is caught with a schema message
668    /// instead of silently ignored.
669    #[test]
670    fn rejects_the_retired_guard_engine_field() {
671        let err = err_of(&GUARDED.replace(
672            "hooks_file = \".demo/hooks.json\"",
673            "engine = \"claude-hooks\"",
674        ));
675        assert!(err.contains("harness-descriptor schema"), "{err}");
676    }
677
678    #[test]
679    fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
680        let mut base: serde_json::Value = toml::from_str(
681            r#"
682label = "demo"
683config_dirs = [".demo"]
684
685[model]
686flag = "--model"
687
688[dispatch]
689capture_prefix = "demo"
690"#,
691        )
692        .unwrap();
693        let overlay: serde_json::Value = toml::from_str(
694            r#"
695label = "demo"
696config_dirs = [".other"]
697
698[model]
699flag = "--model-x"
700"#,
701        )
702        .unwrap();
703        merge_descriptor_value(&mut base, overlay);
704        // Nested-table scalar replaced; sibling table untouched; array replaced
705        // wholesale (no element-wise merge).
706        assert_eq!(base["model"]["flag"], "--model-x");
707        assert_eq!(base["dispatch"]["capture_prefix"], "demo");
708        assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
709    }
710
711    #[test]
712    fn finalize_names_every_contributing_file_on_invariant_breaks() {
713        // A merged value violating an invariant reports the provenance chain,
714        // not a single path.
715        let value: serde_json::Value =
716            toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
717        let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
718            .expect_err("missing config_dirs parent should be rejected")
719            .to_string();
720        assert!(
721            err.contains("base.toml (built-in) + overlay.toml (project)"),
722            "{err}"
723        );
724    }
725
726    #[test]
727    fn subst_replaces_tokens_and_passes_unknown_through() {
728        let out = subst(
729            "run {exec} with ${JOBS:-4} and -I{} on {exec}",
730            &[("exec", "demo-cmd")],
731        );
732        assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
733    }
734
735    #[test]
736    fn subst_does_not_rescan_substituted_values() {
737        let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
738        assert_eq!(out, "holds-{b} second");
739    }
740}