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    /// Tool name of the deterministic skill-invocation event the
192    /// `__skill_invoked` meta-check matches (default `"Skill"` — Claude
193    /// Code's Skill tool).
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub skill_tool: Option<String>,
196    /// Argument of the skill-invocation tool that carries the staged slug
197    /// (default `"skill"`).
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub skill_arg: Option<String>,
200}
201
202impl TranscriptSection {
203    /// Parse the events file into ordered tool invocations, through whichever
204    /// tier the descriptor declares.
205    pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
206        match (&self.parser, &self.extract) {
207            (Some(parser), _) => parser.parse(path),
208            (None, Some(extract)) => super::extract::parse(extract, path),
209            // Validation guarantees one tier is declared; fail like a
210            // parser-less harness if a section slips through unchecked.
211            (None, None) => Err(unwired_error()),
212        }
213    }
214
215    /// Parse the events file into a full [`TranscriptSummary`].
216    pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
217        match (&self.parser, &self.extract) {
218            (Some(parser), _) => parser.parse_full(path),
219            (None, Some(extract)) => super::extract::parse_full(extract, path),
220            (None, None) => Err(unwired_error()),
221        }
222    }
223}
224
225fn unwired_error() -> std::io::Error {
226    std::io::Error::new(
227        std::io::ErrorKind::Unsupported,
228        "[transcript] declares neither a parser nor an extract block",
229    )
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize)]
233pub struct ModelSection {
234    pub flag: String,
235}
236
237/// Which install mechanism the guard engine uses: `json-hooks` (the default)
238/// merges a rendered hook entry into the harness's hook-config file (Claude
239/// Code, Codex); `opencode-plugin` stages an embedded JS plugin whose
240/// `tool.execute.before` hook blocks a tool call by throwing.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
242#[serde(rename_all = "kebab-case")]
243pub enum GuardEngine {
244    #[default]
245    JsonHooks,
246    OpencodePlugin,
247}
248
249impl GuardEngine {
250    /// True at the default — `skip_serializing_if` keeps the discriminator
251    /// out of authorable output for the engine every pre-#155 descriptor uses.
252    fn is_default(&self) -> bool {
253        *self == GuardEngine::default()
254    }
255}
256
257/// The write-guard data block: everything the guard engine ([`super::guard`])
258/// needs to arm the hook surface and render a deny verdict. Which fields apply
259/// depends on `engine`: the JSON-hooks four (`hooks_file`, `matcher`,
260/// `command_template`, `hook_entry`) are required for `json-hooks` and barred
261/// for `opencode-plugin`, which declares `plugin_file` instead — the schema's
262/// conditional-required gate plus load-time validation prove the shape, so the
263/// engine unwraps with "proven at load". Embedded built-in descriptors only —
264/// the guard fails open, so [`layers::check_user_layer_restrictions`] bars user
265/// layers from declaring it.
266#[derive(Debug, Clone, Deserialize, Serialize)]
267pub struct GuardSection {
268    /// Install mechanism; absent means `json-hooks`.
269    #[serde(default, skip_serializing_if = "GuardEngine::is_default")]
270    pub engine: GuardEngine,
271    /// (json-hooks) Hook-config file the entry is merged into, `/`-separated
272    /// relative to the staged env root (e.g. `.claude/settings.local.json`).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub hooks_file: Option<String>,
275    /// (json-hooks) Tool-name matcher the hook registers for; also the source
276    /// of the matcher⊆vocabulary invariant.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub matcher: Option<String>,
279    /// (json-hooks) Shell command the hook runs, with `{exe}` and `{marker}`
280    /// placeholders.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub command_template: Option<String>,
283    /// (json-hooks) JSON template of the hook entry appended to the hook
284    /// config's `hooks.PreToolUse` array, with `{matcher}` and `{command}`
285    /// placeholders in its string values. Authored key order is serialized
286    /// verbatim.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub hook_entry: Option<String>,
289    /// (opencode-plugin) Plugin file staged from the embedded JS template
290    /// (`{exe}`/`{marker}` substituted), `/`-separated relative to the staged
291    /// env root (e.g. `.opencode/plugins/slow-powers-eval-guard.js`).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub plugin_file: Option<String>,
294    /// JSON template of the deny verdict printed on stdout, with a `{reason}`
295    /// placeholder. Authored key order is the harness's on-disk contract.
296    pub verdict_template: String,
297    /// Banner printed after `--guard` arms the hook.
298    pub armed_message: String,
299}
300
301#[derive(Debug, Clone, Deserialize, Serialize)]
302pub struct ShadowSection {
303    pub preflight: ShadowPreflight,
304}
305
306#[derive(Debug, Clone, Default, Deserialize, Serialize)]
307pub struct DispatchSection {
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub capture_prefix: Option<String>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub guard_args: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub model_note: Option<String>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub next_steps_template: Option<String>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub exec_template: Option<String>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub parallel_command_template: Option<String>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub judge_command_template: Option<String>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub manifest_template: Option<String>,
324}
325
326impl DispatchSection {
327    /// True when no dispatch field is set.
328    pub fn is_empty(&self) -> bool {
329        self.capture_prefix.is_none()
330            && self.guard_args.is_none()
331            && self.model_note.is_none()
332            && self.next_steps_template.is_none()
333            && self.exec_template.is_none()
334            && self.parallel_command_template.is_none()
335            && self.judge_command_template.is_none()
336            && self.manifest_template.is_none()
337    }
338}
339
340fn default_true() -> bool {
341    true
342}
343
344/// `skip_serializing_if` helpers for boolean fields at their defaults.
345fn is_false(value: &bool) -> bool {
346    !*value
347}
348
349fn is_true(value: &bool) -> bool {
350    *value
351}
352
353/// A descriptor that failed to load. Every variant carries the descriptor's
354/// source path so the message is actionable on its own.
355#[derive(Debug, thiserror::Error)]
356pub enum DescriptorError {
357    #[error("{path}: invalid TOML: {message}")]
358    Toml { path: String, message: String },
359    #[error(transparent)]
360    Validation(#[from] ValidationError),
361    #[error("{path}: {message}")]
362    Invariant { path: String, message: String },
363}
364
365/// Parse, schema-check, and invariant-check one descriptor. `source` names the
366/// descriptor in error messages (its file path).
367pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
368    finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
369}
370
371/// Parse one descriptor file's TOML into a JSON value and schema-check it.
372/// This is the per-file gate every layered source passes individually — the
373/// schema requires only `label`, so partial override files validate here too,
374/// while unknown fields and bad capability names are still rejected with the
375/// file's own path in the message.
376pub fn parse_descriptor_value(
377    toml_src: &str,
378    source: &str,
379) -> Result<serde_json::Value, DescriptorError> {
380    let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
381        path: source.to_string(),
382        message: e.to_string(),
383    })?;
384    let _: serde_json::Value =
385        validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
386    Ok(value)
387}
388
389/// Merge `overlay` onto `base`, field by field: objects merge recursively per
390/// key; scalars, arrays, and nulls replace wholesale. This is the layering
391/// rule — a later descriptor file overrides individual fields of an earlier
392/// one, never whole-file shadowing.
393pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
394    match (base, overlay) {
395        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
396            for (key, value) in overlay {
397                match base.get_mut(&key) {
398                    Some(slot) if slot.is_object() && value.is_object() => {
399                        merge_descriptor_value(slot, value);
400                    }
401                    _ => {
402                        base.insert(key, value);
403                    }
404                }
405            }
406        }
407        (base, overlay) => *base = overlay,
408    }
409}
410
411/// Deserialize and invariant-check a (possibly merged) descriptor value.
412/// `provenance` names every contributing file (e.g. `base.toml (built-in) +
413/// overlay.toml (project)`) so a post-merge violation is traceable.
414pub fn finalize_descriptor(
415    value: &serde_json::Value,
416    provenance: &str,
417) -> Result<HarnessDescriptor, DescriptorError> {
418    let descriptor: HarnessDescriptor =
419        validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
420    validation::validate_descriptor(&descriptor, provenance)?;
421    Ok(descriptor)
422}
423
424/// Substitute single-brace `{token}` placeholders in `template`.
425///
426/// Single left-to-right pass over the original template: substituted values
427/// are never re-scanned, and unknown or unterminated tokens (shell text like
428/// `${JOBS:-4}` or `-I{}`) pass through verbatim.
429pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
430    let mut out = String::with_capacity(template.len());
431    let mut rest = template;
432    while let Some(start) = rest.find('{') {
433        out.push_str(&rest[..start]);
434        let after = &rest[start + 1..];
435        let Some(end) = after.find('}') else {
436            out.push('{');
437            rest = after;
438            continue;
439        };
440        match vars.iter().find(|(k, _)| *k == &after[..end]) {
441            Some((_, value)) => {
442                out.push_str(value);
443                rest = &after[end + 1..];
444            }
445            None => {
446                out.push('{');
447                rest = after;
448            }
449        }
450    }
451    out.push_str(rest);
452    out
453}
454
455/// The staged-slug shape used when a descriptor declares neither a template
456/// nor a capability.
457pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
458
459/// Render the staged slug for one `(iteration, condition, skill)` cell from
460/// the descriptor's slug capability or template (or the default template).
461pub(crate) fn render_staged_slug(
462    staging: &StagingSection,
463    prefix: &str,
464    iteration: u32,
465    condition: &str,
466    skill_name: &str,
467) -> String {
468    if let Some(capability) = staging.slug_capability {
469        return capability.staged_slug(prefix, iteration, condition, skill_name);
470    }
471    let iteration = iteration.to_string();
472    subst(
473        staging
474            .slug_template
475            .as_deref()
476            .unwrap_or(DEFAULT_SLUG_TEMPLATE),
477        &[
478            ("prefix", prefix),
479            ("iteration", &iteration),
480            ("condition", condition),
481            ("skill_name", skill_name),
482        ],
483    )
484}
485
486/// Check `name` against the descriptor's declarative stage-name rules,
487/// returning the rejection message on violation.
488pub(crate) fn stage_name_error(
489    staging: &StagingSection,
490    regex: Option<&Regex>,
491    name: &str,
492) -> Option<String> {
493    let len_ok = staging
494        .stage_name_max_len
495        .is_none_or(|max| name.len() <= max);
496    let pattern_ok = regex.is_none_or(|r| r.is_match(name));
497    if len_ok && pattern_ok {
498        return None;
499    }
500    Some(match &staging.stage_name_invalid_message {
501        Some(message) => subst(message, &[("name", name)]),
502        None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
503    })
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
511        load_descriptor(toml_src, "test.toml")
512    }
513
514    fn err_of(toml_src: &str) -> String {
515        load(toml_src)
516            .expect_err("descriptor should be rejected")
517            .to_string()
518    }
519
520    const MINIMAL: &str = r#"
521label = "demo"
522skills_dir = ".demo/skills"
523config_dirs = [".demo"]
524"#;
525
526    const GUARDED: &str = r#"
527label = "demo"
528skills_dir = ".demo/skills"
529config_dirs = [".demo"]
530
531[run]
532supports_guard = true
533
534[tools]
535write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
536shell = ["Bash"]
537
538[guard]
539hooks_file = ".demo/hooks.json"
540matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
541command_template = '"{exe}" guard-hook --harness demo "{marker}"'
542hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
543verdict_template = '{"decision":"block","reason":"{reason}"}'
544armed_message = "guard armed"
545"#;
546
547    /// A descriptor whose transcript ingest is the declarative extract tier.
548    const EXTRACTED: &str = r#"
549label = "demo"
550skills_dir = ".demo/skills"
551config_dirs = [".demo"]
552
553[tools]
554write = ["file_change"]
555shell = ["command_execution"]
556
557[transcript]
558events_filename = "demo-events.jsonl"
559
560[transcript.extract.tools]
561where = { type = "item.completed" }
562item = "item"
563name_field = "type"
564skip_names = ["agent_message"]
565args_omit = ["id", "type", "output"]
566result_coalesce = ["output"]
567
568[transcript.extract.final_text]
569where = { type = "item.completed", "item.type" = "agent_message" }
570field = "item.text"
571
572[transcript.extract.tokens]
573where = { type = "turn.completed" }
574sum = ["usage.input_tokens", "usage.output_tokens"]
575
576[transcript.extract.duration]
577timestamp_spread = "timestamp"
578"#;
579
580    #[test]
581    fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
582        let d = load(EXTRACTED).unwrap();
583        let transcript = d.transcript.as_ref().expect("transcript section loads");
584        assert!(transcript.parser.is_none());
585        assert!(transcript.extract.is_some());
586
587        // `harness show` re-serializes resolved descriptors to TOML; the
588        // extract tier must survive the round trip.
589        let shown = toml::to_string(&d).expect("descriptor re-serializes");
590        let reloaded = load(&shown).unwrap();
591        let extract = reloaded.transcript.unwrap().extract.unwrap();
592        assert_eq!(
593            extract.tokens.unwrap().sum,
594            vec!["usage.input_tokens", "usage.output_tokens"]
595        );
596        assert_eq!(
597            extract
598                .tools
599                .unwrap()
600                .r#where
601                .get("type")
602                .map(String::as_str),
603            Some("item.completed")
604        );
605    }
606
607    #[test]
608    fn transcript_section_dispatches_parse_to_the_extract_engine() {
609        let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
610        let dir = tempfile::TempDir::new().unwrap();
611        let path = dir.path().join("demo-events.jsonl");
612        std::fs::write(
613            &path,
614            concat!(
615                r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
616                "\n",
617                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
618                "\n",
619            ),
620        )
621        .unwrap();
622
623        let invocations = transcript.parse(&path).unwrap();
624        assert_eq!(invocations.len(), 1);
625        assert_eq!(invocations[0].name, "command_execution");
626
627        let full = transcript.parse_full(&path).unwrap();
628        assert_eq!(full.final_text, Some("Done.".into()));
629        assert_eq!(full.tool_invocations.len(), 1);
630    }
631
632    #[test]
633    fn minimal_descriptor_loads() {
634        let d = load(MINIMAL).unwrap();
635        assert_eq!(d.label, "demo");
636        assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
637        assert_eq!(d.config_dirs, vec![".demo".to_string()]);
638        // Section defaults match the trait defaults.
639        assert!(!d.run.supports_guard);
640        assert!(d.run.supports_bootstrap_with_no_stage);
641        assert!(d.run.supports_stage_name_with_no_stage);
642        assert!(!d.staging.rewrites_frontmatter_name);
643        assert!(d.guard.is_none());
644        assert!(d.transcript.is_none());
645    }
646
647    #[test]
648    fn guarded_descriptor_loads() {
649        let d = load(GUARDED).unwrap();
650        assert!(d.run.supports_guard);
651        let guard = d.guard.expect("guard section loads");
652        assert_eq!(guard.engine, GuardEngine::JsonHooks);
653        assert_eq!(guard.hooks_file.as_deref(), Some(".demo/hooks.json"));
654        assert_eq!(
655            guard.matcher.as_deref(),
656            Some("Write|Edit|MultiEdit|NotebookEdit|Bash")
657        );
658        assert_eq!(
659            guard.command_template.as_deref(),
660            Some(r#""{exe}" guard-hook --harness demo "{marker}""#)
661        );
662        assert_eq!(guard.plugin_file, None);
663        assert_eq!(guard.armed_message, "guard armed");
664    }
665
666    #[test]
667    fn label_only_descriptor_loads_as_baseline() {
668        let d = load("label = \"demo\"\n").unwrap();
669        assert_eq!(d.label, "demo");
670        assert!(d.skills_dir.is_none(), "no skills dir declared");
671        assert!(d.config_dirs.is_empty());
672        assert!(!d.run.supports_guard);
673    }
674
675    #[test]
676    fn rejects_staging_without_skills_dir() {
677        let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
678        assert!(err.contains("staging"), "{err}");
679        assert!(err.contains("skills_dir"), "{err}");
680    }
681
682    #[test]
683    fn rejects_guard_without_skills_dir() {
684        let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
685        let err = err_of(toml);
686        assert!(err.contains("guard"), "{err}");
687        assert!(err.contains("skills_dir"), "{err}");
688    }
689
690    #[test]
691    fn embedded_descriptors_load_and_validate() {
692        for (source, toml_src) in EMBEDDED_DESCRIPTORS {
693            let d = load_descriptor(toml_src, source)
694                .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
695            assert!(!d.label.is_empty());
696        }
697    }
698
699    #[test]
700    fn rejects_invalid_toml_syntax() {
701        let err = err_of("label = ");
702        assert!(err.contains("test.toml"), "{err}");
703        assert!(err.contains("invalid TOML"), "{err}");
704    }
705
706    #[test]
707    fn rejects_unknown_top_level_field() {
708        let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
709        assert!(err.contains("harness-descriptor schema"), "{err}");
710    }
711
712    #[test]
713    fn rejects_non_kebab_case_label() {
714        let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
715        assert!(err.contains("harness-descriptor schema"), "{err}");
716    }
717
718    /// Migration canary: the pre-#137 named-engine shape must fail the schema
719    /// gate. #155 reintroduced `engine` as a two-variant discriminator
720    /// (`json-hooks`/`opencode-plugin`), so the retired `claude-hooks` name
721    /// now fails the enum — a stale `engine = "..."` line is still caught
722    /// with a schema message instead of silently ignored.
723    #[test]
724    fn rejects_the_retired_guard_engine_field() {
725        let err = err_of(&GUARDED.replace(
726            "hooks_file = \".demo/hooks.json\"",
727            "engine = \"claude-hooks\"",
728        ));
729        assert!(err.contains("harness-descriptor schema"), "{err}");
730    }
731
732    #[test]
733    fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
734        let mut base: serde_json::Value = toml::from_str(
735            r#"
736label = "demo"
737config_dirs = [".demo"]
738
739[model]
740flag = "--model"
741
742[dispatch]
743capture_prefix = "demo"
744"#,
745        )
746        .unwrap();
747        let overlay: serde_json::Value = toml::from_str(
748            r#"
749label = "demo"
750config_dirs = [".other"]
751
752[model]
753flag = "--model-x"
754"#,
755        )
756        .unwrap();
757        merge_descriptor_value(&mut base, overlay);
758        // Nested-table scalar replaced; sibling table untouched; array replaced
759        // wholesale (no element-wise merge).
760        assert_eq!(base["model"]["flag"], "--model-x");
761        assert_eq!(base["dispatch"]["capture_prefix"], "demo");
762        assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
763    }
764
765    #[test]
766    fn finalize_names_every_contributing_file_on_invariant_breaks() {
767        // A merged value violating an invariant reports the provenance chain,
768        // not a single path.
769        let value: serde_json::Value =
770            toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
771        let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
772            .expect_err("missing config_dirs parent should be rejected")
773            .to_string();
774        assert!(
775            err.contains("base.toml (built-in) + overlay.toml (project)"),
776            "{err}"
777        );
778    }
779
780    #[test]
781    fn subst_replaces_tokens_and_passes_unknown_through() {
782        let out = subst(
783            "run {exec} with ${JOBS:-4} and -I{} on {exec}",
784            &[("exec", "demo-cmd")],
785        );
786        assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
787    }
788
789    #[test]
790    fn subst_does_not_rescan_substituted_values() {
791        let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
792        assert_eq!(out, "holds-{b} second");
793    }
794}