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