Skip to main content

ssh_cli/cli/
schema_cmd.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-E2E-02: runtime JSON Schema catalog for agent discovery.
3#![forbid(unsafe_code)]
4//! Embed and emit JSON Schemas from `docs/schemas/` (compile-time include).
5//!
6//! Workload: pure memory lookup (sequential; no fan-out). One-shot: list or body.
7
8use crate::errors::{SshCliError, SshCliResult};
9
10/// Embedded schema catalog: `(name, file leaf, body)`.
11///
12/// Names omit `.schema.json` and match `docs/schemas/README.md`.
13const SCHEMAS: &[(&str, &str, &str)] = &[
14    (
15        "dry-run",
16        "dry-run.schema.json",
17        include_str!("../../docs/schemas/dry-run.schema.json"),
18    ),
19    (
20        "error-envelope",
21        "error-envelope.schema.json",
22        include_str!("../../docs/schemas/error-envelope.schema.json"),
23    ),
24    (
25        "exec",
26        "exec.schema.json",
27        include_str!("../../docs/schemas/exec.schema.json"),
28    ),
29    (
30        "exec-batch",
31        "exec-batch.schema.json",
32        include_str!("../../docs/schemas/exec-batch.schema.json"),
33    ),
34    (
35        "health-check",
36        "health-check.schema.json",
37        include_str!("../../docs/schemas/health-check.schema.json"),
38    ),
39    (
40        "health-check-batch",
41        "health-check-batch.schema.json",
42        include_str!("../../docs/schemas/health-check-batch.schema.json"),
43    ),
44    (
45        "scp-batch",
46        "scp-batch.schema.json",
47        include_str!("../../docs/schemas/scp-batch.schema.json"),
48    ),
49    (
50        "scp-transfer",
51        "scp-transfer.schema.json",
52        include_str!("../../docs/schemas/scp-transfer.schema.json"),
53    ),
54    (
55        "secrets-init",
56        "secrets-init.schema.json",
57        include_str!("../../docs/schemas/secrets-init.schema.json"),
58    ),
59    (
60        "secrets-reencrypt",
61        "secrets-reencrypt.schema.json",
62        include_str!("../../docs/schemas/secrets-reencrypt.schema.json"),
63    ),
64    (
65        "sftp-batch",
66        "sftp-batch.schema.json",
67        include_str!("../../docs/schemas/sftp-batch.schema.json"),
68    ),
69    (
70        "sftp-fs-op",
71        "sftp-fs-op.schema.json",
72        include_str!("../../docs/schemas/sftp-fs-op.schema.json"),
73    ),
74    (
75        "sftp-list",
76        "sftp-list.schema.json",
77        include_str!("../../docs/schemas/sftp-list.schema.json"),
78    ),
79    (
80        "sftp-transfer",
81        "sftp-transfer.schema.json",
82        include_str!("../../docs/schemas/sftp-transfer.schema.json"),
83    ),
84    (
85        "su-exec",
86        "su-exec.schema.json",
87        include_str!("../../docs/schemas/su-exec.schema.json"),
88    ),
89    (
90        "sudo-exec",
91        "sudo-exec.schema.json",
92        include_str!("../../docs/schemas/sudo-exec.schema.json"),
93    ),
94    (
95        "tunnel-closed",
96        "tunnel-closed.schema.json",
97        include_str!("../../docs/schemas/tunnel-closed.schema.json"),
98    ),
99    (
100        "tunnel-listening",
101        "tunnel-listening.schema.json",
102        include_str!("../../docs/schemas/tunnel-listening.schema.json"),
103    ),
104    (
105        "vps-doctor",
106        "vps-doctor.schema.json",
107        include_str!("../../docs/schemas/vps-doctor.schema.json"),
108    ),
109    (
110        "vps-export",
111        "vps-export.schema.json",
112        include_str!("../../docs/schemas/vps-export.schema.json"),
113    ),
114    (
115        "vps-list",
116        "vps-list.schema.json",
117        include_str!("../../docs/schemas/vps-list.schema.json"),
118    ),
119    (
120        "vps-show",
121        "vps-show.schema.json",
122        include_str!("../../docs/schemas/vps-show.schema.json"),
123    ),
124];
125
126/// Runs `ssh-cli schema [NAME]`.
127///
128/// * No name → catalog JSON (`event: schema-catalog`)
129/// * Name → raw JSON Schema document body
130pub fn run_schema(name: Option<&str>, json: bool) -> SshCliResult<()> {
131    match name {
132        None => {
133            let items: Vec<serde_json::Value> = SCHEMAS
134                .iter()
135                .map(|(n, file, _)| {
136                    serde_json::json!({
137                        "name": n,
138                        "file": file,
139                    })
140                })
141                .collect();
142            if json {
143                crate::output::print_json_value(&serde_json::json!({
144                    "ok": true,
145                    "event": "schema-catalog",
146                    "schemas": items,
147                }))?;
148            } else {
149                for (n, file, _) in SCHEMAS {
150                    crate::output::write_line_fmt(format_args!("{n}\t{file}"))?;
151                }
152            }
153            Ok(())
154        }
155        Some(n) => {
156            let body = SCHEMAS
157                .iter()
158                .find(|(name, _, _)| *name == n)
159                .map(|(_, _, body)| *body)
160                .ok_or_else(|| {
161                    SshCliError::InvalidArgument(format!(
162                        "unknown schema '{n}'; run `ssh-cli schema` for the catalog"
163                    ))
164                })?;
165            // Schema body is already JSON; emit raw on stdout (agent contract).
166            crate::output::write_line(body.trim_end())?;
167            Ok(())
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn catalog_non_empty() {
178        assert!(SCHEMAS.len() >= 22);
179    }
180
181    #[test]
182    fn vps_list_present() {
183        assert!(SCHEMAS
184            .iter()
185            .any(|(n, _, b)| *n == "vps-list" && b.contains("schema")));
186    }
187
188    /// Every schema on disk is reachable through `ssh-cli schema`, and vice versa.
189    ///
190    /// `docs/schemas/README.md` tells agents to discover the catalog at runtime, so a
191    /// file that exists on disk but is missing from [`SCHEMAS`] is a published contract
192    /// the consumer cannot fetch: `ssh-cli schema tunnel-closed` answered exit 64
193    /// `unknown schema` while the README documented the very same name. Two schemas had
194    /// drifted that way with no gate to notice, because the only assertions here were a
195    /// lower bound on the count and one hand-picked name.
196    ///
197    /// The reverse direction matters too: a catalog entry without its file cannot exist
198    /// (`include_str!` would fail the build), but a *renamed* file would leave the entry
199    /// pointing at a stale leaf, so both sets are compared.
200    #[test]
201    fn catalog_and_disk_agree() {
202        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/schemas");
203
204        let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
205            .expect("read docs/schemas")
206            .flatten()
207            .filter_map(|e| e.file_name().to_str().map(str::to_owned))
208            .filter(|f| f.ends_with(".schema.json"))
209            .collect();
210        on_disk.sort();
211
212        let mut in_catalog: Vec<String> = SCHEMAS
213            .iter()
214            .map(|(_, file, _)| (*file).to_owned())
215            .collect();
216        in_catalog.sort();
217
218        let missing: Vec<&String> = on_disk.iter().filter(|f| !in_catalog.contains(f)).collect();
219        assert!(
220            missing.is_empty(),
221            "schemas published on disk but absent from the runtime catalog: {missing:?}"
222        );
223
224        let stale: Vec<&String> = in_catalog.iter().filter(|f| !on_disk.contains(f)).collect();
225        assert!(
226            stale.is_empty(),
227            "catalog entries whose file no longer exists on disk: {stale:?}"
228        );
229
230        // Names are the lookup key an agent types; they must be the leaf minus the suffix.
231        for (name, file, _) in SCHEMAS {
232            assert_eq!(
233                *file,
234                format!("{name}.schema.json"),
235                "catalog name '{name}' does not match its file leaf '{file}'"
236            );
237        }
238    }
239}