Skip to main content

futucli/cmd/
surface.rs

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;