Skip to main content

rustio_core/
contract_doctor.rs

1//! Project-side subprocess hook for `rustio doctor --check-schema`
2//! (Phase 14, commit 4).
3//!
4//! # The big picture
5//!
6//! The `rustio` CLI binary doesn't know what models a project has
7//! registered — registration happens in the project's own `main.rs`
8//! via `Admin::new().model::<T>()` calls. To validate the project's
9//! schemas, the CLI spawns the project binary as a subprocess with a
10//! magic flag (`--rustio-doctor-schema-check`); the project binary
11//! short-circuits its normal startup, runs the validator over its
12//! schemas, prints the result, and exits.
13//!
14//! This module is the project-side half of that contract. The CLI
15//! lives in `rustio-cli/src/doctor.rs`; nothing in this module
16//! depends on the CLI.
17//!
18//! # Wiring this into a project's `main.rs`
19//!
20//! Place the call **after** DB connection but **before** server
21//! startup:
22//!
23//! ```ignore
24//! let db = Db::connect(&db_url).await?;
25//! if rustio_core::contract_doctor::maybe_handle_subprocess(
26//!     &db,
27//!     &[Project::SCHEMA, Order::SCHEMA],
28//! ).await {
29//!     return Ok(());
30//! }
31//! // ... normal admin setup, server start, etc.
32//! ```
33//!
34//! Each `T::SCHEMA` is a `const ModelSchema` — calling site evaluates
35//! the const into the array literal. The function takes `&[ModelSchema]`
36//! by reference; ownership of the slice's elements stays with the
37//! caller.
38//!
39//! # What it does
40//!
41//! 1. Reads `std::env::args()` for `--rustio-doctor-schema-check`.
42//! 2. If absent, returns `false` immediately — caller proceeds with
43//!    normal startup.
44//! 3. If present, runs `validate_all` over the supplied schemas.
45//! 4. Prints output (JSON if `--json` is also present, human-readable
46//!    otherwise) to stdout.
47//! 5. Exits the process with code 0 (no errors) or 1 (any errors).
48//!
49//! # Phase scope
50//!
51//! Commit 4 ships the helper + its CLI subprocess driver. Nothing
52//! in `admin/`, `search/`, `migrations.rs`, or the contract types
53//! themselves is changed.
54
55use crate::contract::ModelSchema;
56use crate::contract_validator::{validate_all, IssueKind, ReportStatus, SchemaReport};
57use crate::orm::Db;
58
59// ---------------------------------------------------------------------------
60// Magic flag
61// ---------------------------------------------------------------------------
62
63/// The argv flag the CLI passes to the project binary. Long, prefixed
64/// with `--rustio-doctor-` so it can't collide with project-defined
65/// flags.
66pub const SCHEMA_CHECK_FLAG: &str = "--rustio-doctor-schema-check";
67
68/// Optional companion flag that asks for JSON output instead of the
69/// human-readable renderer. The CLI always passes this when
70/// subprocessing — the CLI's `--json` decides whether to pass-through
71/// the JSON or pretty-print, but the project always emits JSON when
72/// running headless.
73pub const JSON_FLAG: &str = "--json";
74
75// ---------------------------------------------------------------------------
76// Public entry point
77// ---------------------------------------------------------------------------
78
79/// If the current process was invoked with `--rustio-doctor-schema-check`,
80/// run the validator and exit. Returns `true` only after exiting (so
81/// the return value is conceptually unreachable — but typed for
82/// clarity at call sites). Returns `false` immediately when the flag
83/// isn't set, so the caller's normal startup proceeds.
84///
85/// ```ignore
86/// // In project main.rs:
87/// if rustio_core::contract_doctor::maybe_handle_subprocess(
88///     &db,
89///     &[Project::SCHEMA],
90/// ).await {
91///     return Ok(());
92/// }
93/// ```
94pub 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    // The validator's `validate_all` requires `&[&'static ModelSchema]`.
102    // We accept owned/borrowed slices for caller ergonomics, then leak
103    // each schema one-shot. Acceptable: this function exits the
104    // process, so the leak is transient.
105    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        // Always one line of canonical JSON to stdout — easy for the
116        // CLI to capture and pass through.
117        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        // Human-readable: one line per table with status symbol +
124        // first issue (if any). The CLI also has a renderer, but
125        // this path runs when a developer invokes the project
126        // binary directly (e.g. for debugging without the CLI).
127        for r in &reports {
128            print_human_line(r);
129        }
130    }
131
132    std::process::exit(exit_code);
133}
134
135// ---------------------------------------------------------------------------
136// Pure helpers — exposed crate-internal so the CLI can reuse them.
137// ---------------------------------------------------------------------------
138
139/// Decide the process exit code from the report set. Any error in
140/// any report → exit 1; otherwise exit 0. Warnings alone never
141/// fail the check (consistent with `rustio doctor`'s
142/// READY (DEGRADED) behaviour).
143pub(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
151/// Top-level overall status string — derived from per-report
152/// statuses. Mirrors the doctor's vocabulary: `"ok"` /
153/// `"warning"` / `"error"`. Lowercase for JSON.
154pub(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
164/// Build the canonical JSON document the CLI consumes. Schema:
165///
166/// ```json
167/// {
168///   "status": "ok" | "warning" | "error",
169///   "tables": [
170///     {
171///       "table": "...",
172///       "status": "...",
173///       "errors":   [{ column?, kind, message, expected?, actual? }, ...],
174///       "warnings": [...same...]
175///     },
176///     ...
177///   ]
178/// }
179/// ```
180///
181/// Manual serialisation (no `Serialize` derive) — touching the
182/// `SchemaReport` / `SchemaIssue` types in `contract_validator.rs`
183/// would violate commit 4's "don't touch the validator" rule. Keys
184/// are stable; new ones can be added without breaking older clients.
185pub(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
219/// Stable lowercase identifier per `IssueKind` variant. Used in JSON
220/// so a CI script doing `jq '.tables[].errors[].kind'` gets a
221/// predictable string.
222fn 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
234/// Render one report as a single human-readable line:
235///
236/// ```text
237/// ✓ projects
238/// ✗ invoices (missing_column: column `invoices.amount` ...)
239/// ⚠ clients (extra_db_column: column `clients.legacy_code` ...)
240/// ```
241fn 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// ---------------------------------------------------------------------------
264// Tests
265// ---------------------------------------------------------------------------
266
267#[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    // ----- Exit-code derivation -----
310
311    #[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        // Spec parity with `rustio doctor`: warnings don't fail the
324        // check. Only errors do.
325        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    // ----- Overall status string -----
344
345    #[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    // ----- JSON shape -----
374
375    #[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        // The IssueKind serialisation is the contract a CI script
420        // does `jq '.tables[].errors[] | select(.kind=="missing_column")'`
421        // against. Lock the strings.
422        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        // Mixed table statuses → overall "error".
454        assert_eq!(doc["status"], "error");
455    }
456
457    // ----- Magic flag constants are stable -----
458
459    #[test]
460    fn magic_flag_strings_are_stable() {
461        // The CLI side hardcodes the same string literals; if either
462        // side drifts the subprocess goes silent. This test is a
463        // tripwire for that.
464        assert_eq!(SCHEMA_CHECK_FLAG, "--rustio-doctor-schema-check");
465        assert_eq!(JSON_FLAG, "--json");
466    }
467}