1use crate::contract::ModelSchema;
56use crate::contract_validator::{validate_all, IssueKind, ReportStatus, SchemaReport};
57use crate::orm::Db;
58
59pub const SCHEMA_CHECK_FLAG: &str = "--rustio-doctor-schema-check";
67
68pub const JSON_FLAG: &str = "--json";
74
75pub async fn maybe_handle_subprocess(db: &Db, schemas: &[ModelSchema]) -> bool {
95 let args: Vec<String> = std::env::args().collect();
96 if !args.iter().any(|a| a == SCHEMA_CHECK_FLAG) {
97 return false;
98 }
99 let json_mode = args.iter().any(|a| a == JSON_FLAG);
100
101 let leaked: Vec<&'static ModelSchema> = schemas
106 .iter()
107 .cloned()
108 .map(|s| &*Box::leak(Box::new(s)))
109 .collect();
110
111 let reports = validate_all(db, &leaked).await;
112 let exit_code = exit_code_for(&reports);
113
114 if json_mode {
115 let doc = reports_to_json(&reports);
118 match serde_json::to_string(&doc) {
119 Ok(s) => println!("{s}"),
120 Err(e) => eprintln!("contract_doctor: failed to serialise JSON: {e}"),
121 }
122 } else {
123 for r in &reports {
128 print_human_line(r);
129 }
130 }
131
132 std::process::exit(exit_code);
133}
134
135pub(crate) fn exit_code_for(reports: &[SchemaReport]) -> i32 {
144 if reports.iter().any(|r| r.has_errors()) {
145 1
146 } else {
147 0
148 }
149}
150
151pub(crate) fn overall_status_str(reports: &[SchemaReport]) -> &'static str {
155 if reports.iter().any(|r| r.has_errors()) {
156 "error"
157 } else if reports.iter().any(|r| !r.warnings.is_empty()) {
158 "warning"
159 } else {
160 "ok"
161 }
162}
163
164pub(crate) fn reports_to_json(reports: &[SchemaReport]) -> serde_json::Value {
186 serde_json::json!({
187 "status": overall_status_str(reports),
188 "tables": reports.iter().map(report_to_json).collect::<Vec<_>>(),
189 })
190}
191
192fn report_to_json(r: &SchemaReport) -> serde_json::Value {
193 serde_json::json!({
194 "table": r.table,
195 "status": status_str(r.status),
196 "errors": r.errors.iter().map(issue_to_json).collect::<Vec<_>>(),
197 "warnings": r.warnings.iter().map(issue_to_json).collect::<Vec<_>>(),
198 })
199}
200
201fn issue_to_json(i: &crate::contract_validator::SchemaIssue) -> serde_json::Value {
202 serde_json::json!({
203 "column": i.column,
204 "kind": issue_kind_str(i.kind),
205 "message": i.message,
206 "expected": i.expected,
207 "actual": i.actual,
208 })
209}
210
211fn status_str(s: ReportStatus) -> &'static str {
212 match s {
213 ReportStatus::Ok => "ok",
214 ReportStatus::Warning => "warning",
215 ReportStatus::Error => "error",
216 }
217}
218
219fn issue_kind_str(k: IssueKind) -> &'static str {
223 match k {
224 IssueKind::MissingTable => "missing_table",
225 IssueKind::MissingColumn => "missing_column",
226 IssueKind::TypeMismatch => "type_mismatch",
227 IssueKind::NullabilityMismatch => "nullability_mismatch",
228 IssueKind::WrongPrimaryKey => "wrong_primary_key",
229 IssueKind::ExtraDbColumn => "extra_db_column",
230 IssueKind::QueryFailed => "query_failed",
231 }
232}
233
234fn print_human_line(r: &SchemaReport) {
242 match r.status {
243 ReportStatus::Ok => println!("✓ {}", r.table),
244 ReportStatus::Warning => {
245 let first = r
246 .warnings
247 .first()
248 .map(|i| format!("{}: {}", issue_kind_str(i.kind), i.message))
249 .unwrap_or_else(|| "warning".into());
250 println!("⚠ {} ({first})", r.table);
251 }
252 ReportStatus::Error => {
253 let first = r
254 .errors
255 .first()
256 .map(|i| format!("{}: {}", issue_kind_str(i.kind), i.message))
257 .unwrap_or_else(|| "error".into());
258 println!("✗ {} ({first})", r.table);
259 }
260 }
261}
262
263#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::contract_validator::{IssueKind, ReportStatus, SchemaIssue, SchemaReport};
271
272 fn ok_report(table: &str) -> SchemaReport {
273 SchemaReport {
274 table: table.into(),
275 status: ReportStatus::Ok,
276 errors: vec![],
277 warnings: vec![],
278 }
279 }
280 fn warn_report(table: &str, kind: IssueKind, msg: &str) -> SchemaReport {
281 SchemaReport {
282 table: table.into(),
283 status: ReportStatus::Warning,
284 errors: vec![],
285 warnings: vec![SchemaIssue {
286 column: Some("c".into()),
287 kind,
288 message: msg.into(),
289 expected: None,
290 actual: None,
291 }],
292 }
293 }
294 fn err_report(table: &str, kind: IssueKind, msg: &str) -> SchemaReport {
295 SchemaReport {
296 table: table.into(),
297 status: ReportStatus::Error,
298 errors: vec![SchemaIssue {
299 column: Some("c".into()),
300 kind,
301 message: msg.into(),
302 expected: Some("expected".into()),
303 actual: Some("actual".into()),
304 }],
305 warnings: vec![],
306 }
307 }
308
309 #[test]
312 fn exit_code_zero_on_empty_input() {
313 assert_eq!(exit_code_for(&[]), 0);
314 }
315
316 #[test]
317 fn exit_code_zero_on_all_ok() {
318 assert_eq!(exit_code_for(&[ok_report("a"), ok_report("b")]), 0);
319 }
320
321 #[test]
322 fn exit_code_zero_on_warnings_only() {
323 assert_eq!(
326 exit_code_for(&[warn_report("a", IssueKind::ExtraDbColumn, "x")]),
327 0
328 );
329 }
330
331 #[test]
332 fn exit_code_one_on_any_error() {
333 assert_eq!(
334 exit_code_for(&[
335 ok_report("a"),
336 warn_report("b", IssueKind::ExtraDbColumn, "x"),
337 err_report("c", IssueKind::MissingColumn, "y"),
338 ]),
339 1
340 );
341 }
342
343 #[test]
346 fn overall_status_ok_when_all_clean() {
347 assert_eq!(overall_status_str(&[ok_report("a")]), "ok");
348 assert_eq!(overall_status_str(&[]), "ok");
349 }
350
351 #[test]
352 fn overall_status_warning_when_warnings_only() {
353 assert_eq!(
354 overall_status_str(&[
355 ok_report("a"),
356 warn_report("b", IssueKind::ExtraDbColumn, "x"),
357 ]),
358 "warning"
359 );
360 }
361
362 #[test]
363 fn overall_status_error_takes_priority_over_warnings() {
364 assert_eq!(
365 overall_status_str(&[
366 warn_report("a", IssueKind::ExtraDbColumn, "x"),
367 err_report("b", IssueKind::TypeMismatch, "y"),
368 ]),
369 "error"
370 );
371 }
372
373 #[test]
376 fn json_top_level_has_status_and_tables() {
377 let doc = reports_to_json(&[ok_report("projects")]);
378 let obj = doc.as_object().expect("top-level must be an object");
379 assert!(obj.contains_key("status"));
380 assert!(obj.contains_key("tables"));
381 assert_eq!(obj.len(), 2, "no extra keys at top level");
382 }
383
384 #[test]
385 fn json_table_entries_have_required_fields() {
386 let doc = reports_to_json(&[err_report("invoices", IssueKind::MissingColumn, "msg")]);
387 let table = &doc["tables"][0];
388 let obj = table.as_object().expect("table entry must be object");
389 for k in ["table", "status", "errors", "warnings"] {
390 assert!(obj.contains_key(k), "missing key: {k}");
391 }
392 assert_eq!(obj.len(), 4, "no extra keys per table");
393 }
394
395 #[test]
396 fn json_issue_has_all_five_fields() {
397 let doc = reports_to_json(&[err_report("t", IssueKind::TypeMismatch, "m")]);
398 let issue = &doc["tables"][0]["errors"][0];
399 let obj = issue.as_object().expect("issue must be object");
400 for k in ["column", "kind", "message", "expected", "actual"] {
401 assert!(obj.contains_key(k), "missing issue key: {k}");
402 }
403 assert_eq!(obj.len(), 5);
404 }
405
406 #[test]
407 fn json_status_is_lowercase_string() {
408 let doc = reports_to_json(&[
409 ok_report("a"),
410 warn_report("b", IssueKind::ExtraDbColumn, "x"),
411 ]);
412 assert_eq!(doc["status"], "warning");
413 assert_eq!(doc["tables"][0]["status"], "ok");
414 assert_eq!(doc["tables"][1]["status"], "warning");
415 }
416
417 #[test]
418 fn json_kind_uses_stable_snake_case() {
419 for (kind, expected) in [
423 (IssueKind::MissingTable, "missing_table"),
424 (IssueKind::MissingColumn, "missing_column"),
425 (IssueKind::TypeMismatch, "type_mismatch"),
426 (IssueKind::NullabilityMismatch, "nullability_mismatch"),
427 (IssueKind::WrongPrimaryKey, "wrong_primary_key"),
428 (IssueKind::ExtraDbColumn, "extra_db_column"),
429 (IssueKind::QueryFailed, "query_failed"),
430 ] {
431 assert_eq!(issue_kind_str(kind), expected, "kind {kind:?}");
432 }
433 }
434
435 #[test]
436 fn json_round_trips_through_serde_json() {
437 let doc = reports_to_json(&[
438 ok_report("projects"),
439 warn_report("clients", IssueKind::ExtraDbColumn, "extra"),
440 err_report("invoices", IssueKind::MissingColumn, "missing"),
441 ]);
442 let s = serde_json::to_string(&doc).expect("serialise");
443 let parsed: serde_json::Value = serde_json::from_str(&s).expect("round-trip");
444 assert_eq!(parsed, doc);
445 }
446
447 #[test]
448 fn json_overall_status_reflects_table_mix() {
449 let doc = reports_to_json(&[
450 ok_report("a"),
451 err_report("b", IssueKind::MissingColumn, "x"),
452 ]);
453 assert_eq!(doc["status"], "error");
455 }
456
457 #[test]
460 fn magic_flag_strings_are_stable() {
461 assert_eq!(SCHEMA_CHECK_FLAG, "--rustio-doctor-schema-check");
465 assert_eq!(JSON_FLAG, "--json");
466 }
467}