Skip to main content

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}