Skip to main content

siderite_cli/report/
snapshot.rs

1//! A bounded, versioned snapshot of an application's metadata: the
2//! framework version, compiled features, database aliases with their
3//! backend kinds, registered models, documented routes and the OpenAPI
4//! document.
5//!
6//! The snapshot is built from compiled metadata only (no connection is
7//! opened) and is safe to hand to tools: database URLs are never included
8//! (only alias names and backend kinds), every string of the OpenAPI
9//! document and the app description is redacted, and no row data exists
10//! to leak. Each section says whether it is complete; anything left out
11//! to respect [`Limits`] is reported, never silently dropped.
12
13use super::build::{model_reports, route_reports};
14use super::redact_text;
15use super::{Diagnostic, Envelope, ModelReport, RouteReport, Severity};
16use crate::connect::backend_kind;
17use crate::settings::CliSettings;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use siderite_core::App;
21use siderite_orm::ModelMeta;
22
23/// The `format` of a snapshot.
24pub const FORMAT: &str = "siderite-snapshot";
25/// Bumped only for breaking changes of the snapshot shape.
26pub const FORMAT_VERSION: u32 = 1;
27
28/// Size bounds of a snapshot.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Limits {
31    /// Most bytes of the serialized snapshot.
32    pub max_bytes: usize,
33    /// Most models.
34    pub max_models: usize,
35    /// Most routes.
36    pub max_routes: usize,
37    /// Most bytes of the embedded OpenAPI document.
38    pub max_openapi_bytes: usize,
39}
40
41impl Default for Limits {
42    fn default() -> Self {
43        Self {
44            max_bytes: 1 << 20,
45            max_models: 500,
46            max_routes: 2_000,
47            max_openapi_bytes: 512 << 10,
48        }
49    }
50}
51
52/// The framework that produced the snapshot.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Framework {
55    /// `siderite`.
56    pub name: String,
57    /// Version of the framework compiled into the app.
58    pub version: String,
59}
60
61/// The app's OpenAPI metadata.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AppInfo {
64    /// API title.
65    pub title: String,
66    /// API version.
67    pub version: String,
68    /// API description (redacted).
69    pub description: Option<String>,
70}
71
72/// A configured database alias. The URL is never included.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct DatabaseInfo {
75    /// Alias name.
76    pub alias: String,
77    /// `sqlite`, `postgres`, `mysql`, `mongodb`, `redis` or `unknown`.
78    pub backend: String,
79}
80
81/// Whether a section holds everything, and why not.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Section {
84    /// Nothing was left out.
85    pub complete: bool,
86    /// How many items exist, when known.
87    pub total: Option<usize>,
88    /// How many are included.
89    pub included: usize,
90    /// Why the section is incomplete.
91    pub reason: Option<String>,
92}
93
94impl Section {
95    fn full(count: usize) -> Self {
96        Self {
97            complete: true,
98            total: Some(count),
99            included: count,
100            reason: None,
101        }
102    }
103
104    fn missing(reason: &str) -> Self {
105        Self {
106            complete: false,
107            total: None,
108            included: 0,
109            reason: Some(reason.to_owned()),
110        }
111    }
112}
113
114/// Completeness of each section.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Completeness {
117    /// The `models` section.
118    pub models: Section,
119    /// The `routes` section.
120    pub routes: Section,
121    /// The `openapi` section (`included` is 1 or 0).
122    pub openapi: Section,
123}
124
125/// The snapshot (the `data` of a `snapshot` envelope).
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct Snapshot {
128    /// Always [`FORMAT`].
129    pub format: String,
130    /// [`FORMAT_VERSION`].
131    pub format_version: u32,
132    /// The framework.
133    pub framework: Framework,
134    /// The app.
135    pub app: AppInfo,
136    /// `siderite-cli` features compiled into the app binary.
137    pub features: Vec<String>,
138    /// Configured aliases, sorted by name.
139    pub databases: Vec<DatabaseInfo>,
140    /// Registered models, sorted by name then table.
141    pub models: Vec<ModelReport>,
142    /// Documented routes, sorted by path then method.
143    pub routes: Vec<RouteReport>,
144    /// The OpenAPI document, redacted, when it fits.
145    pub openapi: Option<Value>,
146    /// What each section holds.
147    pub completeness: Completeness,
148}
149
150/// Build the snapshot of `app`, its registered `models` and `settings`
151/// within `limits`.
152///
153/// Args:
154///   app: A freshly built app (no connection is opened).
155///   models: The registered model metadata.
156///   settings: The CLI settings; only alias names and URL schemes are
157///     read.
158///   limits: Size bounds.
159///
160/// Returns:
161///   The `snapshot` envelope. A truncated section adds a
162///   `snapshot.W001` warning; an OpenAPI document that cannot be
163///   generated adds a `snapshot.E001` error and leaves routes and spec
164///   empty and marked incomplete.
165pub fn snapshot(
166    app: &App,
167    models: &[&'static ModelMeta],
168    settings: &CliSettings,
169    limits: Limits,
170) -> Envelope<Snapshot> {
171    let mut diags = Vec::new();
172    let mut models = model_reports(models);
173    let max = limits.max_models;
174    let models_section = cap(&mut models, max, "models", &mut diags);
175    let documented = documented(app, limits, &mut diags);
176    let (routes, openapi, routes_section, openapi_section) = documented;
177    let meta = app.meta();
178    let mut snapshot = Snapshot {
179        format: FORMAT.to_owned(),
180        format_version: FORMAT_VERSION,
181        framework: Framework {
182            name: "siderite".to_owned(),
183            version: env!("CARGO_PKG_VERSION").to_owned(),
184        },
185        app: AppInfo {
186            title: redact_text(&meta.title),
187            version: redact_text(&meta.version),
188            description: meta.description.as_deref().map(redact_text),
189        },
190        features: features(),
191        databases: databases(settings),
192        models,
193        routes,
194        openapi,
195        completeness: Completeness {
196            models: models_section,
197            routes: routes_section,
198            openapi: openapi_section,
199        },
200    };
201    fit(&mut snapshot, limits.max_bytes, &mut diags);
202    Envelope::new("snapshot", snapshot, diags)
203}
204
205type Documented = (Vec<RouteReport>, Option<Value>, Section, Section);
206type Diags<'a> = &'a mut Vec<Diagnostic>;
207
208/// Routes and the redacted OpenAPI document, each within its limit.
209fn documented(app: &App, limits: Limits, diags: Diags<'_>) -> Documented {
210    let (mut routes, document) = match (route_reports(app), app.openapi()) {
211        (Ok(routes), Ok(document)) => (routes, document.to_value()),
212        (Err(err), _) => return unavailable(&err.to_string(), diags),
213        (_, Err(err)) => return unavailable(&err.to_string(), diags),
214    };
215    let routes_section = cap(&mut routes, limits.max_routes, "routes", diags);
216    let document = redact_value(document);
217    let size = serde_json::to_vec(&document).map_or(usize::MAX, |b| b.len());
218    if size > limits.max_openapi_bytes {
219        let reason = format!(
220            "the OpenAPI document has {size} bytes, over the limit of {}",
221            limits.max_openapi_bytes
222        );
223        diags.push(truncated(&reason));
224        let mut section = Section::missing(&reason);
225        section.total = Some(1);
226        return (routes, None, routes_section, section);
227    }
228    (routes, Some(document), routes_section, Section::full(1))
229}
230
231fn unavailable(message: &str, diags: &mut Vec<Diagnostic>) -> Documented {
232    let reason = "the OpenAPI document cannot be generated";
233    let diag = Diagnostic::new("snapshot.E001", Severity::Error, message)
234        .with_hint("run `check` for the duplicate or invalid route");
235    diags.push(diag);
236    let section = Section::missing(reason);
237    (Vec::new(), None, section.clone(), section)
238}
239
240/// Keep the first `max` items (they are sorted), reporting a cut.
241fn cap<T>(items: &mut Vec<T>, max: usize, what: &str, d: Diags) -> Section {
242    let total = items.len();
243    if total <= max {
244        return Section::full(total);
245    }
246    items.truncate(max);
247    let reason = format!("{total} {what}, over the limit of {max}");
248    d.push(truncated(&reason));
249    Section {
250        complete: false,
251        total: Some(total),
252        included: max,
253        reason: Some(reason),
254    }
255}
256
257fn truncated(reason: &str) -> Diagnostic {
258    Diagnostic::new("snapshot.W001", Severity::Warning, reason)
259        .with_hint("the snapshot is partial; see `completeness`")
260}
261
262/// Shrink to `max_bytes`: drop the OpenAPI document first, then routes
263/// and models from the end, so the result is deterministic. Stops when
264/// it fits or nothing is left to drop.
265fn fit(snapshot: &mut Snapshot, max_bytes: usize, diags: Diags<'_>) {
266    let reason = format!("the snapshot is over {max_bytes} bytes");
267    let mut reported = false;
268    loop {
269        let over = json_len(snapshot).saturating_sub(max_bytes);
270        if over == 0 {
271            return;
272        }
273        if !reported {
274            diags.push(truncated(&reason));
275            reported = true;
276        }
277        if snapshot.openapi.take().is_some() {
278            snapshot.completeness.openapi = Section::missing(&reason);
279        } else if !snapshot.routes.is_empty() {
280            let left = drop_bytes(&mut snapshot.routes, over);
281            mark(&mut snapshot.completeness.routes, left, &reason);
282        } else if !snapshot.models.is_empty() {
283            let left = drop_bytes(&mut snapshot.models, over);
284            mark(&mut snapshot.completeness.models, left, &reason);
285        } else {
286            return;
287        }
288    }
289}
290
291/// Pop items until about `bytes` are gone; the items left.
292fn drop_bytes<T: Serialize>(items: &mut Vec<T>, bytes: usize) -> usize {
293    let mut freed = 0;
294    while freed < bytes {
295        let Some(item) = items.pop() else { break };
296        // The item and the comma before it.
297        freed += json_len(&item) + 1;
298    }
299    items.len()
300}
301
302fn json_len<T: Serialize>(item: &T) -> usize {
303    serde_json::to_vec(item).map_or(0, |b| b.len())
304}
305
306fn mark(section: &mut Section, included: usize, reason: &str) {
307    section.complete = false;
308    section.included = included;
309    section.reason = Some(reason.to_owned());
310}
311
312/// Every string in `value`, redacted.
313fn redact_value(value: Value) -> Value {
314    match value {
315        Value::String(text) => Value::String(redact_text(&text)),
316        Value::Array(items) => {
317            let items = items.into_iter().map(redact_value);
318            Value::Array(items.collect())
319        }
320        Value::Object(map) => {
321            let map = map.into_iter().map(|(k, v)| (k, redact_value(v)));
322            Value::Object(map.collect())
323        }
324        other => other,
325    }
326}
327
328/// `siderite-cli` features compiled into this binary.
329fn features() -> Vec<String> {
330    let all = [
331        ("mysql", cfg!(feature = "mysql")),
332        ("postgres", cfg!(feature = "postgres")),
333    ];
334    all.iter()
335        .filter(|(_, on)| *on)
336        .map(|(f, _)| (*f).to_owned())
337        .collect()
338}
339
340/// Alias names and backend kinds; URLs never leave this function.
341fn databases(settings: &CliSettings) -> Vec<DatabaseInfo> {
342    settings
343        .database_urls()
344        .iter()
345        .map(|(alias, url)| DatabaseInfo {
346            alias: alias.clone(),
347            backend: backend_kind(url).map_or_else(
348                || "unknown".to_owned(),
349                |kind| format!("{kind:?}").to_ascii_lowercase(),
350            ),
351        })
352        .collect()
353}