1use anyhow::{Result, bail};
2use futu_surface_spec::{
3 EndpointSpec, EndpointTransport, GrpcSurface, SchemaCoverage, SurfaceExposure, SurfaceKind,
4};
5use serde::Serialize;
6use tabled::Tabled;
7
8use crate::output::OutputFormat;
9
10const SURFACE_CLOSURE_GUARDRAILS: &str = "includes surface_catalog_guard.sh, parity_fixture_oracle_guard.sh, projection_domain_ledger_guard.sh, public_error_hint_contract_guard.sh";
11
12#[derive(Debug, Clone, Serialize, Tabled)]
13pub(crate) struct SurfaceRow {
14 #[tabled(rename = "Endpoint")]
15 pub(crate) canonical: String,
16 #[tabled(rename = "Proto")]
17 pub(crate) proto_id: String,
18 #[tabled(rename = "REST")]
19 pub(crate) rest: String,
20 #[tabled(rename = "MCP")]
21 pub(crate) mcp: String,
22 #[tabled(rename = "CLI")]
23 pub(crate) cli: String,
24 #[tabled(rename = "Gateway")]
25 pub(crate) gateway: String,
26 #[tabled(rename = "Scope")]
27 pub(crate) scope: String,
28 #[tabled(rename = "Fields")]
29 pub(crate) field_count: usize,
30 #[serde(skip)]
31 #[tabled(skip)]
32 pub(crate) has_gap: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Tabled)]
36pub(crate) struct SurfaceChecklistRow {
37 #[tabled(rename = "Area")]
38 pub(crate) area: String,
39 #[tabled(rename = "Status")]
40 pub(crate) status: String,
41 #[tabled(rename = "Action")]
42 pub(crate) action: String,
43}
44
45pub fn run(gaps: bool, checklist: Option<&str>, output: OutputFormat) -> Result<()> {
46 if let Some(endpoint) = checklist {
47 let rows = surface_checklist_rows(endpoint)?;
48 if matches!(output, OutputFormat::Markdown) {
49 println!("{}", render_surface_checklist_markdown(endpoint, &rows));
50 } else {
51 output.print_rows(&rows, &rows)?;
52 }
53 return Ok(());
54 }
55
56 let rows = surface_rows(gaps);
57 output.print_rows(&rows, &rows)?;
58 Ok(())
59}
60
61pub(crate) fn surface_rows(gaps_only: bool) -> Vec<SurfaceRow> {
62 futu_surface_spec::ALL_ENDPOINTS
63 .iter()
64 .map(|spec| row_for_spec(spec))
65 .filter(|row| !gaps_only || row.has_gap)
66 .collect()
67}
68
69fn row_for_spec(spec: &EndpointSpec) -> SurfaceRow {
70 let rest = exposure_text(spec.surface_names.rest_path);
71 let rest = if spec.surface_names.rest_aliases.is_empty() {
72 rest
73 } else {
74 format!(
75 "{} aliases={}",
76 rest,
77 spec.surface_names.rest_aliases.join(",")
78 )
79 };
80 SurfaceRow {
81 canonical: spec.canonical_name.to_string(),
82 proto_id: proto_text(spec.transport),
83 rest,
84 mcp: exposure_text(spec.surface_names.mcp_tool),
85 cli: exposure_text(spec.surface_names.cli_subcommand),
86 gateway: exposure_text(spec.surface_names.gateway_proto_id),
87 scope: format!("{:?}", spec.runtime.scope),
88 field_count: spec.schema.fields.len(),
89 has_gap: spec.surface_names.rest_path.not_exposed_reason().is_some()
90 || spec.surface_names.mcp_tool.not_exposed_reason().is_some()
91 || spec
92 .surface_names
93 .cli_subcommand
94 .not_exposed_reason()
95 .is_some()
96 || spec
97 .surface_names
98 .gateway_proto_id
99 .not_exposed_reason()
100 .is_some()
101 || spec.grpc_exposure().not_exposed_reason().is_some(),
102 }
103}
104
105pub(crate) fn surface_checklist_rows(endpoint: &str) -> Result<Vec<SurfaceChecklistRow>> {
106 let spec = find_endpoint(endpoint)?;
107 let rows = vec![
108 checklist_row(
109 "EndpointSpec",
110 format!("registered: {}", spec.canonical_name),
111 "Keep endpoints/<snake>.rs, pub mod/use re-export, ALL_ENDPOINTS entry, and inventory tests in the same commit",
112 ),
113 checklist_row(
114 "Transport",
115 proto_text(spec.transport),
116 "Check proto_id/gRPC exposure; daemon-local/internal endpoints need explicit NotExposed reasons",
117 ),
118 checklist_row(
119 "Schema",
120 format!(
121 "fields={} c2s={} s2c={} proto={}",
122 spec.schema.fields.len(),
123 empty_as_none(spec.schema.c2s_module_path),
124 empty_as_none(spec.schema.s2c_module_path),
125 empty_as_none(spec.schema.proto_file_path),
126 ),
127 "Update FieldSpec, validation fn, proto path/root, then run schema_drift",
128 ),
129 checklist_row(
130 "Runtime",
131 format!(
132 "scope={:?} side_effect={:?} idempotency={:?} fail_closed={} error={:?}",
133 spec.runtime.scope,
134 spec.runtime.side_effects,
135 spec.runtime.idempotency,
136 spec.runtime.fail_closed,
137 spec.runtime.error.backend_error_policy,
138 ),
139 "Update RuntimeContract and run runtime_contract; writes must fail closed and preserve backend errors",
140 ),
141 surface_row(
142 "REST",
143 spec.surface_names.rest_path,
144 format_rest_action(spec),
145 ),
146 surface_row(
147 "MCP",
148 spec.surface_names.mcp_tool,
149 "Register tool + scope guard + docs; run cross_surface_consistency and MCP strict output guards",
150 ),
151 surface_row(
152 "CLI",
153 spec.surface_names.cli_subcommand,
154 "Register clap command + dispatch + parser tests + CLI docs; run cross_surface_consistency",
155 ),
156 surface_row(
157 "Gateway",
158 spec.surface_names.gateway_proto_id,
159 "Register proto handler/dispatch or keep explicit NotExposed reason; run gateway cross-surface tests",
160 ),
161 checklist_row(
162 "gRPC",
163 grpc_exposure_text(spec.grpc_exposure()),
164 "Proto-backed endpoints use generic gRPC request; daemon-local/internal endpoints must stay NotExposed",
165 ),
166 checklist_row(
167 "Field coverage",
168 schema_coverage_summary(spec),
169 "Update surface_contract field rows for MCP/CLI/Rust SDK typed adapters; run surface_contract",
170 ),
171 checklist_row(
172 "Diagnostics",
173 "guarded by diagnostic contract/bundle + architecture evidence ledger",
174 "If behavior changes diagnostics, update diagnostic_contract.sh, doctor/bundle docs, and run check_surface_closure.sh",
175 ),
176 checklist_row(
177 "Final gate",
178 "one command",
179 format!(
180 "Run bash scripts/check_surface_closure.sh before commit ({SURFACE_CLOSURE_GUARDRAILS}); add focused runtime tests for any changed handler"
181 ),
182 ),
183 ];
184
185 Ok(rows)
186}
187
188pub(crate) fn render_surface_checklist_markdown(
189 endpoint: &str,
190 rows: &[SurfaceChecklistRow],
191) -> String {
192 let mut out = String::new();
193 out.push_str(&format!(
194 "### Surface Checklist: `{}`\n\n",
195 escape_markdown_inline(endpoint.trim())
196 ));
197 out.push_str("| Area | Status | Action |\n");
198 out.push_str("| --- | --- | --- |\n");
199 for row in rows {
200 out.push_str("| ");
201 out.push_str(&escape_markdown_cell(&row.area));
202 out.push_str(" | ");
203 out.push_str(&format_status_markdown(&row.status));
204 out.push_str(" | ");
205 out.push_str(&format_action_markdown(&row.action));
206 out.push_str(" |\n");
207 }
208 out
209}
210
211fn format_status_markdown(value: &str) -> String {
212 let escaped = escape_markdown_cell(value);
213 if should_wrap_status_in_code(value) {
214 format!("`{escaped}`")
215 } else {
216 escaped
217 }
218}
219
220fn should_wrap_status_in_code(value: &str) -> bool {
221 value.starts_with('/')
222 || value.starts_with("futu_")
223 || value.chars().all(|ch| ch.is_ascii_lowercase() || ch == '-')
224}
225
226fn format_action_markdown(value: &str) -> String {
227 let escaped = escape_markdown_cell(value);
228 escaped.replace(
229 "bash scripts/check_surface_closure.sh",
230 "`bash scripts/check_surface_closure.sh`",
231 )
232}
233
234fn escape_markdown_cell(value: &str) -> String {
235 escape_markdown_inline(value)
236 .replace('\n', "<br>")
237 .replace('|', "\\|")
238}
239
240fn escape_markdown_inline(value: &str) -> String {
241 value.replace('`', "\\`")
242}
243
244fn find_endpoint(query: &str) -> Result<&'static EndpointSpec> {
245 let query = query.trim();
246 if query.is_empty() {
247 bail!("unknown endpoint: empty query");
248 }
249
250 futu_surface_spec::ALL_ENDPOINTS
251 .iter()
252 .copied()
253 .find(|spec| endpoint_matches(spec, query))
254 .ok_or_else(|| {
255 anyhow::anyhow!(
256 "unknown endpoint {query:?}; pass canonical name, REST path, MCP tool, or CLI subcommand"
257 )
258 })
259}
260
261fn endpoint_matches(spec: &EndpointSpec, query: &str) -> bool {
262 spec.canonical_name.eq_ignore_ascii_case(query)
263 || spec.surface_names.rest_path.exposed() == Some(query)
264 || spec.surface_names.rest_aliases.contains(&query)
265 || spec.surface_names.mcp_tool.exposed() == Some(query)
266 || spec.surface_names.cli_subcommand.exposed() == Some(query)
267 || spec
268 .surface_names
269 .gateway_proto_id
270 .exposed()
271 .is_some_and(|proto_id| query.parse::<u32>().ok() == Some(proto_id))
272}
273
274fn checklist_row(
275 area: impl Into<String>,
276 status: impl Into<String>,
277 action: impl Into<String>,
278) -> SurfaceChecklistRow {
279 SurfaceChecklistRow {
280 area: area.into(),
281 status: status.into(),
282 action: action.into(),
283 }
284}
285
286fn surface_row<T: ToString + Copy>(
287 area: &'static str,
288 exposure: SurfaceExposure<T>,
289 exposed_action: impl Into<String>,
290) -> SurfaceChecklistRow {
291 let action = match exposure {
292 SurfaceExposure::Exposed(_) => exposed_action.into(),
293 SurfaceExposure::NotExposed { .. } => {
294 "Keep the NotExposed reason explicit and covered by endpoint_registry".to_string()
295 }
296 };
297
298 checklist_row(area, exposure_text(exposure), action)
299}
300
301fn format_rest_action(spec: &EndpointSpec) -> String {
302 let alias_note = if spec.surface_names.rest_aliases.is_empty() {
303 "avoid new aliases unless needed for compatibility".to_string()
304 } else {
305 format!("aliases={}", spec.surface_names.rest_aliases.join(","))
306 };
307 format!(
308 "Register axum route + strict fields + REST docs; {alias_note}; run futu-rest strict tests"
309 )
310}
311
312fn schema_coverage_summary(spec: &EndpointSpec) -> String {
313 let parts = [
314 ("gateway", spec.schema_coverage(SurfaceKind::Gateway)),
315 ("rest", spec.schema_coverage(SurfaceKind::Rest)),
316 ("mcp", spec.schema_coverage(SurfaceKind::Mcp)),
317 ("cli", spec.schema_coverage(SurfaceKind::Cli)),
318 ("grpc", spec.schema_coverage(SurfaceKind::Grpc)),
319 ];
320 parts
321 .into_iter()
322 .map(|(surface, coverage)| format!("{surface}={}", schema_coverage_text(coverage)))
323 .collect::<Vec<_>>()
324 .join(" ")
325}
326
327fn schema_coverage_text(coverage: SchemaCoverage) -> String {
328 match coverage {
329 SchemaCoverage::NotExposed { reason } => format!("not-exposed({reason})"),
330 SchemaCoverage::SpecJsonValidation => "spec-json-validation".to_string(),
331 SchemaCoverage::TypedAdapterArgs => "typed-adapter-args".to_string(),
332 SchemaCoverage::TypedProtoBody => "typed-proto-body".to_string(),
333 SchemaCoverage::GrpcMetadataOnly => "grpc-metadata-only".to_string(),
334 }
335}
336
337fn grpc_exposure_text(exposure: SurfaceExposure<GrpcSurface>) -> String {
338 match exposure {
339 SurfaceExposure::Exposed(value) => format!("{value:?}"),
340 SurfaceExposure::NotExposed { reason } => format!("not exposed: {reason}"),
341 }
342}
343
344fn empty_as_none(value: &str) -> &str {
345 if value.is_empty() { "<none>" } else { value }
346}
347
348fn proto_text(transport: EndpointTransport) -> String {
349 match transport {
350 EndpointTransport::Proto { proto_id } => proto_id.to_string(),
351 EndpointTransport::DaemonLocal => "daemon-local".to_string(),
352 EndpointTransport::DaemonInternal { proto_id } => format!("daemon-internal:{proto_id}"),
353 }
354}
355
356fn exposure_text<T: ToString + Copy>(exposure: SurfaceExposure<T>) -> String {
357 match exposure {
358 SurfaceExposure::Exposed(value) => value.to_string(),
359 SurfaceExposure::NotExposed { reason } => format!("not exposed: {reason}"),
360 }
361}
362
363#[cfg(test)]
364#[path = "surface_tests.rs"]
365mod surface_tests;