1use 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
23pub const FORMAT: &str = "siderite-snapshot";
25pub const FORMAT_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Limits {
31 pub max_bytes: usize,
33 pub max_models: usize,
35 pub max_routes: usize,
37 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Framework {
55 pub name: String,
57 pub version: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AppInfo {
64 pub title: String,
66 pub version: String,
68 pub description: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct DatabaseInfo {
75 pub alias: String,
77 pub backend: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Section {
84 pub complete: bool,
86 pub total: Option<usize>,
88 pub included: usize,
90 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Completeness {
117 pub models: Section,
119 pub routes: Section,
121 pub openapi: Section,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct Snapshot {
128 pub format: String,
130 pub format_version: u32,
132 pub framework: Framework,
134 pub app: AppInfo,
136 pub features: Vec<String>,
138 pub databases: Vec<DatabaseInfo>,
140 pub models: Vec<ModelReport>,
142 pub routes: Vec<RouteReport>,
144 pub openapi: Option<Value>,
146 pub completeness: Completeness,
148}
149
150pub 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
208fn 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
240fn 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
262fn 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
291fn 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 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
312fn 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
328fn 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
340fn 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}