Skip to main content

agent_runtime/render/
support_matrix.rs

1//! Shared support matrix renderer.
2//!
3//! The target consumes `<source-root>/manifests/surfaces.yaml` and writes the
4//! derived human-readable view to `build/shared/SUPPORT_MATRIX.md`. The root
5//! Markdown file is not parsed as input; row data comes from the manifest.
6
7use crate::render::manifest::{SCHEMA_VERSION, SourceRoot};
8use crate::render::writer::{guard_write_under, sandboxed_join};
9use anyhow::{Context, Result, anyhow};
10use serde::Deserialize;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14const MANIFEST_NAME: &str = "surfaces.yaml";
15const EXPECTED_TARGET: &str = "support-matrix";
16const DEFAULT_OUTPUT: &str = "build/shared/SUPPORT_MATRIX.md";
17
18#[derive(Debug, PartialEq, Eq)]
19pub struct SupportMatrixReport {
20    pub output_path: PathBuf,
21    pub surfaces: usize,
22    pub rows: usize,
23}
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct SurfacesManifest {
28    schema_version: u32,
29    render: SurfaceRenderConfig,
30    surfaces: Vec<Surface>,
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct SurfaceRenderConfig {
36    target: String,
37    root_view: String,
38    output: String,
39}
40
41#[derive(Debug, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct Surface {
44    id: String,
45    ordinal: u32,
46    name: String,
47    products: SurfaceProducts,
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct SurfaceProducts {
53    codex: SurfaceProduct,
54    claude: SurfaceProduct,
55    // Optional so codex/claude-only surface manifests (and the crate's own
56    // fixtures) still parse. The runtime-kit enforces hermes presence on
57    // every surface through `scripts/ci/validate-surfaces-manifest.sh`.
58    #[serde(default)]
59    hermes: Option<SurfaceProduct>,
60}
61
62impl SurfaceProducts {
63    fn iter(&self) -> Vec<(&'static str, &SurfaceProduct)> {
64        let mut out = vec![("codex", &self.codex), ("claude", &self.claude)];
65        if let Some(hermes) = &self.hermes {
66            out.push(("hermes", hermes));
67        }
68        out
69    }
70}
71
72#[derive(Debug, Deserialize)]
73#[serde(deny_unknown_fields)]
74struct SurfaceProduct {
75    state: SurfaceState,
76    mechanism: String,
77    source_artifacts: Vec<String>,
78    min_product: String,
79    min_nils_cli: String,
80    acceptance: Vec<Acceptance>,
81    source_manifest: Vec<String>,
82}
83
84#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "kebab-case")]
86enum SurfaceState {
87    Shipped,
88    Partial,
89    PlannedNotShipped,
90    NotShipped,
91    NotApplicable,
92}
93
94impl SurfaceState {
95    fn as_str(self) -> &'static str {
96        match self {
97            Self::Shipped => "shipped",
98            Self::Partial => "partial",
99            Self::PlannedNotShipped => "planned-not-shipped",
100            Self::NotShipped => "not-shipped",
101            Self::NotApplicable => "not-applicable",
102        }
103    }
104}
105
106#[derive(Debug, Deserialize)]
107#[serde(deny_unknown_fields)]
108struct Acceptance {
109    kind: AcceptanceKind,
110    #[serde(default)]
111    command: Option<String>,
112    #[serde(default)]
113    note: Option<String>,
114    #[serde(default)]
115    success: Option<AcceptanceSuccess>,
116    #[serde(default)]
117    descriptive_only: bool,
118}
119
120#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "kebab-case")]
122enum AcceptanceKind {
123    Ci,
124    Live,
125}
126
127#[derive(Debug, Deserialize)]
128#[serde(deny_unknown_fields)]
129struct AcceptanceSuccess {
130    exit_status: u8,
131}
132
133pub fn render(root: &SourceRoot) -> Result<SupportMatrixReport> {
134    let manifest = load(root)?;
135    validate(&manifest)?;
136    let markdown = render_markdown(&manifest);
137    let output_path = output_path(root, &manifest.render.output)?;
138    if let Some(parent) = output_path.parent() {
139        fs::create_dir_all(parent)
140            .with_context(|| format!("create_dir_all {}", parent.display()))?;
141    }
142    let guarded = guard_write_under(root.path(), &output_path)?;
143    fs::write(&guarded, markdown.as_bytes())
144        .with_context(|| format!("write {}", guarded.display()))?;
145    Ok(SupportMatrixReport {
146        output_path: guarded,
147        surfaces: manifest.surfaces.len(),
148        rows: manifest
149            .surfaces
150            .iter()
151            .map(|surface| surface.products.iter().len())
152            .sum(),
153    })
154}
155
156pub fn update_golden(source_root: &Path, report: &SupportMatrixReport) -> Result<PathBuf> {
157    let golden = source_root
158        .join("tests")
159        .join("golden")
160        .join("shared")
161        .join("SUPPORT_MATRIX.md");
162    if let Some(parent) = golden.parent() {
163        fs::create_dir_all(parent)
164            .with_context(|| format!("create_dir_all {}", parent.display()))?;
165    }
166    fs::copy(&report.output_path, &golden).with_context(|| {
167        format!(
168            "copy {} -> {}",
169            report.output_path.display(),
170            golden.display()
171        )
172    })?;
173    Ok(golden)
174}
175
176fn load(root: &SourceRoot) -> Result<SurfacesManifest> {
177    let path = root.manifests_dir().join(MANIFEST_NAME);
178    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
179    serde_yaml_ng::from_str(&raw).with_context(|| format!("parse {}", path.display()))
180}
181
182fn validate(manifest: &SurfacesManifest) -> Result<()> {
183    if manifest.schema_version != SCHEMA_VERSION {
184        return Err(anyhow!(
185            "schema_version mismatch in manifests/{MANIFEST_NAME}: expected {SCHEMA_VERSION}, got {}",
186            manifest.schema_version
187        ));
188    }
189    if manifest.render.target != EXPECTED_TARGET {
190        return Err(anyhow!(
191            "render.target must be `{EXPECTED_TARGET}`, got `{}`",
192            manifest.render.target
193        ));
194    }
195    if manifest.render.root_view.trim().is_empty() {
196        return Err(anyhow!("render.root_view must not be empty"));
197    }
198    if manifest.render.output.trim().is_empty() {
199        return Err(anyhow!("render.output must not be empty"));
200    }
201
202    let mut seen = std::collections::BTreeSet::new();
203    for surface in &manifest.surfaces {
204        if surface.id.trim().is_empty() {
205            return Err(anyhow!("surface ordinal {} has empty id", surface.ordinal));
206        }
207        if !seen.insert(surface.id.as_str()) {
208            return Err(anyhow!("duplicate surface id `{}`", surface.id));
209        }
210        if surface.name.trim().is_empty() {
211            return Err(anyhow!("surface `{}` has empty name", surface.id));
212        }
213        for (product, entry) in surface.products.iter() {
214            validate_product(surface, product, entry)?;
215        }
216    }
217    Ok(())
218}
219
220fn validate_product(surface: &Surface, product: &str, entry: &SurfaceProduct) -> Result<()> {
221    if entry.mechanism.trim().is_empty() {
222        return Err(anyhow!(
223            "surface `{}` product `{product}` has empty mechanism",
224            surface.id
225        ));
226    }
227    if entry.min_product.trim().is_empty() {
228        return Err(anyhow!(
229            "surface `{}` product `{product}` has empty min_product",
230            surface.id
231        ));
232    }
233    if entry.min_nils_cli.trim().is_empty() {
234        return Err(anyhow!(
235            "surface `{}` product `{product}` has empty min_nils_cli",
236            surface.id
237        ));
238    }
239    for item in &entry.acceptance {
240        let has_command = item
241            .command
242            .as_deref()
243            .is_some_and(|value| !value.trim().is_empty());
244        let has_note = item
245            .note
246            .as_deref()
247            .is_some_and(|value| !value.trim().is_empty());
248        match (has_command, has_note) {
249            (true, false) => {
250                if item.descriptive_only {
251                    return Err(anyhow!(
252                        "surface `{}` product `{product}` command acceptance cannot be descriptive_only",
253                        surface.id
254                    ));
255                }
256                if item.success.is_none() {
257                    return Err(anyhow!(
258                        "surface `{}` product `{product}` command acceptance requires success",
259                        surface.id
260                    ));
261                }
262            }
263            (false, true) => {
264                if !item.descriptive_only {
265                    return Err(anyhow!(
266                        "surface `{}` product `{product}` note acceptance requires descriptive_only=true",
267                        surface.id
268                    ));
269                }
270                if item.success.is_some() {
271                    return Err(anyhow!(
272                        "surface `{}` product `{product}` note acceptance must not include success",
273                        surface.id
274                    ));
275                }
276            }
277            _ => {
278                return Err(anyhow!(
279                    "surface `{}` product `{product}` acceptance must contain exactly one of command or note",
280                    surface.id
281                ));
282            }
283        }
284    }
285    Ok(())
286}
287
288fn output_path(root: &SourceRoot, output: &str) -> Result<PathBuf> {
289    let rel = if output.trim().is_empty() {
290        DEFAULT_OUTPUT
291    } else {
292        output
293    };
294    sandboxed_join(root.path(), rel)
295}
296
297fn render_markdown(manifest: &SurfacesManifest) -> String {
298    let mut lines = vec![
299        "# SUPPORT_MATRIX".to_string(),
300        String::new(),
301        "<!-- Generated by `agent-runtime render --target support-matrix`; edit `manifests/surfaces.yaml`. -->".to_string(),
302        String::new(),
303        "Unified human-readable view of which Codex, Claude, and Hermes harness primitives `agent-runtime-kit` ships into today, by what mechanism, and at what version floor.".to_string(),
304        String::new(),
305        "## Matrix".to_string(),
306        String::new(),
307        "| surface | product | state | mechanism | source_artifact | min_product | min_nils_cli | ci_acceptance | live_acceptance | source_manifest |".to_string(),
308        "|---|---|---|---|---|---|---|---|---|---|".to_string(),
309    ];
310
311    for surface in &manifest.surfaces {
312        for (product, entry) in surface.products.iter() {
313            lines.push(render_row(surface, product, entry));
314        }
315    }
316    lines.push(String::new());
317    lines.join("\n")
318}
319
320fn render_row(surface: &Surface, product: &str, entry: &SurfaceProduct) -> String {
321    let surface_name = format!("{}. {}", surface.ordinal, surface.name);
322    let ci = render_acceptance(&entry.acceptance, AcceptanceKind::Ci);
323    let live = render_acceptance(&entry.acceptance, AcceptanceKind::Live);
324    format!(
325        "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
326        cell(&surface_name),
327        cell(product),
328        cell(entry.state.as_str()),
329        cell(&entry.mechanism),
330        cell(&render_list(&entry.source_artifacts, true)),
331        cell(&entry.min_product),
332        cell(&entry.min_nils_cli),
333        cell(&ci),
334        cell(&live),
335        cell(&render_list(&entry.source_manifest, true)),
336    )
337}
338
339fn render_acceptance(items: &[Acceptance], kind: AcceptanceKind) -> String {
340    let rendered = items
341        .iter()
342        .filter(|item| item.kind == kind)
343        .map(|item| {
344            if let Some(command) = item
345                .command
346                .as_deref()
347                .map(str::trim)
348                .filter(|v| !v.is_empty())
349            {
350                let success = item
351                    .success
352                    .as_ref()
353                    .map(|success| format!(" (exit {})", success.exit_status))
354                    .unwrap_or_default();
355                format!("`{command}`{success}")
356            } else {
357                item.note
358                    .as_deref()
359                    .map(str::trim)
360                    .filter(|v| !v.is_empty())
361                    .unwrap_or("—")
362                    .to_string()
363            }
364        })
365        .collect::<Vec<_>>();
366    render_list(&rendered, false)
367}
368
369fn render_list(items: &[String], code: bool) -> String {
370    let non_empty = items
371        .iter()
372        .map(|item| item.trim())
373        .filter(|item| !item.is_empty())
374        .map(|item| {
375            if code && item != "—" {
376                format!("`{item}`")
377            } else {
378                item.to_string()
379            }
380        })
381        .collect::<Vec<_>>();
382    if non_empty.is_empty() {
383        "—".to_string()
384    } else {
385        non_empty.join("<br>")
386    }
387}
388
389fn cell(value: &str) -> String {
390    value.replace('|', "\\|").replace('\n', "<br>")
391}