1use 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 #[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}