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