siderite_cli/report/mod.rs
1//! Typed reports and the output contract shared by the text and JSON
2//! renderers.
3//!
4//! The layers are separate on purpose:
5//!
6//! 1. **Builders** ([`build`]) are pure: they read the app, the registered
7//! models and the settings and return typed reports. They open no
8//! database connection and print nothing.
9//! 2. **Renderers** ([`text`], [`json`]) turn a report into a string. No
10//! renderer parses another renderer's output.
11//!
12//! Every report travels in an [`Envelope`] whose `schema_version` only
13//! changes for breaking changes; new fields are additive. The JSON Schema
14//! is `docs/schemas/cli-report-v1.json`.
15//!
16//! Exit codes are `0` (success), `1` (failure, including a `check` that
17//! found an error) and `2` (usage error); see [`EXIT_OK`].
18
19pub mod build;
20pub mod channel;
21pub mod json;
22pub mod snapshot;
23pub mod text;
24
25#[cfg(test)]
26mod tests;
27#[cfg(test)]
28mod tests_snapshot;
29
30pub use siderite_config::redact::{REDACTED, redact_field, redact_text};
31
32use serde::ser::SerializeStruct;
33use serde::{Deserialize, Serialize, Serializer};
34
35/// Version of the JSON envelope. Bumped only for breaking changes.
36pub const SCHEMA_VERSION: u32 = 1;
37
38/// Exit code of a successful command.
39pub const EXIT_OK: u8 = 0;
40/// Exit code of a failed command.
41pub const EXIT_FAILURE: u8 = 1;
42/// Exit code of a usage error.
43pub const EXIT_USAGE: u8 = 2;
44
45/// How serious a [`Diagnostic`] is.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Severity {
49 /// Informational only. Reserved: no command emits it yet, but
50 /// consumers must accept it, and it never makes a report fail.
51 Info,
52 /// Worth fixing; the app still runs.
53 Warning,
54 /// The app or its migrations will not work.
55 Error,
56}
57
58impl Severity {
59 /// The lower-case name used in text and JSON output.
60 pub const fn as_str(self) -> &'static str {
61 match self {
62 Self::Info => "info",
63 Self::Warning => "warning",
64 Self::Error => "error",
65 }
66 }
67}
68
69/// One finding.
70///
71/// The fields are private so that every diagnostic is built through
72/// [`Diagnostic::new`], which passes free text through [`redact_text`].
73/// Serialization and the text renderer redact again, so a deserialized
74/// diagnostic cannot leak a connection string either.
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
76pub struct Diagnostic {
77 code: String,
78 severity: Severity,
79 message: String,
80 #[serde(default)]
81 location: Option<String>,
82 #[serde(default)]
83 hint: Option<String>,
84}
85
86impl Diagnostic {
87 /// Create a diagnostic with a redacted `message`.
88 ///
89 /// Args:
90 /// code: Stable identifier such as `models.E003`.
91 /// severity: How serious the finding is.
92 /// msg: Human-readable description.
93 ///
94 /// Returns:
95 /// The diagnostic, without location or hint.
96 pub fn new(code: &str, severity: Severity, msg: &str) -> Self {
97 Self {
98 code: code.to_owned(),
99 severity,
100 message: redact_text(msg),
101 location: None,
102 hint: None,
103 }
104 }
105
106 /// Attach a redacted location.
107 #[must_use]
108 pub fn with_location(mut self, location: &str) -> Self {
109 self.location = Some(redact_text(location));
110 self
111 }
112
113 /// Attach a redacted hint.
114 #[must_use]
115 pub fn with_hint(mut self, hint: &str) -> Self {
116 self.hint = Some(redact_text(hint));
117 self
118 }
119
120 /// Stable identifier such as `models.E003`.
121 pub fn code(&self) -> &str {
122 &self.code
123 }
124
125 /// How serious the finding is.
126 pub const fn severity(&self) -> Severity {
127 self.severity
128 }
129
130 /// Human-readable description.
131 pub fn message(&self) -> &str {
132 &self.message
133 }
134
135 /// Where the problem is (a file, model or route), if known.
136 pub fn location(&self) -> Option<&str> {
137 self.location.as_deref()
138 }
139
140 /// How to fix it, if known.
141 pub fn hint(&self) -> Option<&str> {
142 self.hint.as_deref()
143 }
144}
145
146impl Serialize for Diagnostic {
147 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
148 let optional = [self.location.is_some(), self.hint.is_some()];
149 let len = 3 + optional.iter().filter(|set| **set).count();
150 let mut out = ser.serialize_struct("Diagnostic", len)?;
151 out.serialize_field("code", &self.code)?;
152 out.serialize_field("severity", &self.severity)?;
153 out.serialize_field("message", &redact_text(&self.message))?;
154 if let Some(location) = &self.location {
155 out.serialize_field("location", &redact_text(location))?;
156 }
157 if let Some(hint) = &self.hint {
158 out.serialize_field("hint", &redact_text(hint))?;
159 }
160 out.end()
161 }
162}
163
164/// The versioned wrapper around every structured report.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct Envelope<T> {
167 /// Envelope version, [`SCHEMA_VERSION`].
168 pub schema_version: u32,
169 /// The command that produced the report (`check`, `routes`, ...).
170 pub command: String,
171 /// Whether the command succeeded.
172 pub ok: bool,
173 /// The command's report.
174 pub data: T,
175 /// Findings, in a stable order.
176 pub diagnostics: Vec<Diagnostic>,
177}
178
179impl<T> Envelope<T> {
180 /// A report whose success is decided by its diagnostics: it is `ok`
181 /// unless one of them is an error.
182 pub fn new(command: &str, data: T, diags: Vec<Diagnostic>) -> Self {
183 let ok = !diags.iter().any(|d| d.severity == Severity::Error);
184 Self {
185 schema_version: SCHEMA_VERSION,
186 command: command.to_owned(),
187 ok,
188 data,
189 diagnostics: diags,
190 }
191 }
192
193 /// The process exit code of the command: [`EXIT_OK`] or
194 /// [`EXIT_FAILURE`].
195 pub const fn exit_code(&self) -> u8 {
196 if self.ok { EXIT_OK } else { EXIT_FAILURE }
197 }
198}
199
200/// Summary of a `check` run (the `data` of its envelope).
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202pub struct CheckSummary {
203 /// Number of error diagnostics.
204 pub errors: usize,
205 /// Number of warning diagnostics.
206 pub warnings: usize,
207}
208
209/// One documented route.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct RouteReport {
212 /// Upper-case HTTP method.
213 pub method: String,
214 /// Full path template, including mount prefixes.
215 pub path: String,
216 /// The operation id, if the endpoint has one.
217 pub operation_id: Option<String>,
218 /// OpenAPI tags, sorted.
219 pub tags: Vec<String>,
220 /// One-line summary, if documented.
221 pub summary: Option<String>,
222}
223
224/// The `data` of a `routes` envelope. Routes are the documented routes
225/// of the OpenAPI document (hidden endpoints are omitted), sorted by path
226/// then method.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct RoutesData {
229 /// The routes.
230 pub routes: Vec<RouteReport>,
231}
232
233/// One column of a registered model.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct FieldReport {
236 /// Rust field name.
237 pub name: String,
238 /// Column name.
239 pub column: String,
240 /// Column type family (`BigInt`, `Text`, ...).
241 #[serde(rename = "type")]
242 pub sql_type: String,
243 /// Accepts `NULL`.
244 pub nullable: bool,
245 /// Part of the primary key.
246 pub pk: bool,
247}
248
249/// One registered model.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct ModelReport {
252 /// Model name.
253 pub name: String,
254 /// Table name.
255 pub table: String,
256 /// Whether migrations manage the table.
257 pub managed: bool,
258 /// Fields, sorted by name then column.
259 pub fields: Vec<FieldReport>,
260}
261
262/// The `data` of a models report, sorted by name then table.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct ModelsData {
265 /// The models.
266 pub models: Vec<ModelReport>,
267}
268
269/// One migration in a `showmigrations` report.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct MigrationReport {
272 /// Migration id.
273 pub id: String,
274 /// Whether the history table records it as applied.
275 pub applied: bool,
276}
277
278/// Where the database URL of a live command came from. The URL itself is
279/// never reported.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "lowercase")]
282pub enum UrlSource {
283 /// `--database-url`.
284 Flag,
285 /// The URL configured for the alias in the application settings.
286 Settings,
287 /// The `DATABASE_URL` environment variable.
288 Environment,
289}
290
291/// The `data` of a `showmigrations` envelope: the same rows, in the same
292/// order, as the text output.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub struct MigrationStatusData {
295 /// The selected database alias; `null` for the standalone binary,
296 /// which has no aliases.
297 pub alias: Option<String>,
298 /// Where the database URL came from.
299 pub source: UrlSource,
300 /// Every migration with its applied flag.
301 pub migrations: Vec<MigrationReport>,
302 /// Number of applied migrations.
303 pub applied: usize,
304 /// Number of pending migrations.
305 pub pending: usize,
306}