Skip to main content

waypoint_core/
advisor.rs

1//! Schema advisor: proactive suggestions for schema improvements.
2//!
3//! This module owns the engine-agnostic types and the dialect-aware
4//! dispatcher. The actual rule implementations live in
5//! [`crate::engines::postgres::advisor`] (rules A001-A010) and
6//! [`crate::engines::mysql::advisor`] (rules M001-M005).
7//!
8//! Rule IDs are namespaced per engine so JSON consumers can ignore the
9//! dialect — they share the same [`Advisory`] / [`AdvisorReport`] types.
10
11use serde::Serialize;
12
13use crate::db::DbClient;
14use crate::dialect::DialectKind;
15use crate::error::Result;
16
17// ── Re-exports of the engine-specific entry points ──────────────────────────
18
19#[cfg(feature = "mysql")]
20pub use crate::engines::mysql::advisor::analyze as analyze_mysql;
21#[cfg(feature = "postgres")]
22pub use crate::engines::postgres::advisor::analyze;
23
24// ── Shared types ────────────────────────────────────────────────────────────
25
26/// Configuration for the schema advisor.
27#[derive(Debug, Clone, Default)]
28pub struct AdvisorConfig {
29    /// Whether to run the advisor after migrations.
30    pub run_after_migrate: bool,
31    /// List of rule IDs to disable (e.g., ["A003", "A006"]).
32    pub disabled_rules: Vec<String>,
33}
34
35/// Severity of an advisory.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "lowercase")]
38pub enum AdvisorySeverity {
39    Info,
40    Suggestion,
41    Warning,
42}
43
44impl std::fmt::Display for AdvisorySeverity {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Info => write!(f, "info"),
48            Self::Suggestion => write!(f, "suggestion"),
49            Self::Warning => write!(f, "warning"),
50        }
51    }
52}
53
54/// A single advisory finding.
55#[derive(Debug, Clone, Serialize)]
56pub struct Advisory {
57    /// Rule ID (e.g., "A001").
58    pub rule_id: String,
59    /// Category of the advisory.
60    pub category: String,
61    /// Severity level.
62    pub severity: AdvisorySeverity,
63    /// Affected database object (e.g., "users.email", "idx_name").
64    pub object: String,
65    /// Human-readable explanation of the issue.
66    pub explanation: String,
67    /// Generated SQL to fix the issue.
68    pub fix_sql: Option<String>,
69}
70
71/// Report from the schema advisor.
72#[derive(Debug, Clone, Serialize)]
73pub struct AdvisorReport {
74    /// Schema that was analyzed.
75    pub schema: String,
76    /// All advisory findings.
77    pub advisories: Vec<Advisory>,
78    /// Count of warnings.
79    pub warning_count: usize,
80    /// Count of suggestions.
81    pub suggestion_count: usize,
82    /// Count of info items.
83    pub info_count: usize,
84}
85
86/// Run all advisory rules against the database schema (dialect-aware entry).
87pub async fn analyze_db(
88    client: &DbClient,
89    schema: &str,
90    config: &AdvisorConfig,
91) -> Result<AdvisorReport> {
92    match client.dialect_kind() {
93        #[cfg(feature = "postgres")]
94        DialectKind::Postgres => analyze(client.as_postgres()?, schema, config).await,
95        #[cfg(not(feature = "postgres"))]
96        DialectKind::Postgres => Err(crate::error::WaypointError::ConfigError(
97            "PostgreSQL support is not compiled in".into(),
98        )),
99        #[cfg(feature = "mysql")]
100        DialectKind::Mysql => analyze_mysql(client, schema, config).await,
101        #[cfg(not(feature = "mysql"))]
102        DialectKind::Mysql => Err(crate::error::WaypointError::ConfigError(
103            "MySQL support is not compiled in".into(),
104        )),
105    }
106}
107
108/// Generate combined fix SQL from all advisories.
109pub fn generate_fix_sql(report: &AdvisorReport) -> String {
110    let fixes: Vec<String> = report
111        .advisories
112        .iter()
113        .filter_map(|a| {
114            a.fix_sql.as_ref().map(|sql| {
115                format!(
116                    "-- {} [{}]: {}\n{}",
117                    a.rule_id, a.severity, a.explanation, sql
118                )
119            })
120        })
121        .collect();
122    fixes.join("\n\n")
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_advisor_config_default() {
131        let config = AdvisorConfig::default();
132        assert!(!config.run_after_migrate);
133        assert!(config.disabled_rules.is_empty());
134    }
135
136    #[test]
137    fn test_generate_fix_sql_empty() {
138        let report = AdvisorReport {
139            schema: "public".to_string(),
140            advisories: vec![],
141            warning_count: 0,
142            suggestion_count: 0,
143            info_count: 0,
144        };
145        assert!(generate_fix_sql(&report).is_empty());
146    }
147
148    #[test]
149    fn test_generate_fix_sql_with_advisories() {
150        let report = AdvisorReport {
151            schema: "public".to_string(),
152            advisories: vec![
153                Advisory {
154                    rule_id: "A001".to_string(),
155                    category: "Performance".to_string(),
156                    severity: AdvisorySeverity::Warning,
157                    object: "orders.user_id".to_string(),
158                    explanation: "FK without index".to_string(),
159                    fix_sql: Some(
160                        "CREATE INDEX idx_orders_user_id ON \"orders\" (\"user_id\");".to_string(),
161                    ),
162                },
163                Advisory {
164                    rule_id: "A004".to_string(),
165                    category: "Correctness".to_string(),
166                    severity: AdvisorySeverity::Warning,
167                    object: "logs".to_string(),
168                    explanation: "No primary key".to_string(),
169                    fix_sql: None,
170                },
171            ],
172            warning_count: 2,
173            suggestion_count: 0,
174            info_count: 0,
175        };
176        let sql = generate_fix_sql(&report);
177        assert!(sql.contains("CREATE INDEX"));
178        assert!(sql.contains("A001"));
179        assert!(!sql.contains("A004"));
180    }
181
182    #[test]
183    fn test_advisory_severity_display() {
184        assert_eq!(AdvisorySeverity::Info.to_string(), "info");
185        assert_eq!(AdvisorySeverity::Suggestion.to_string(), "suggestion");
186        assert_eq!(AdvisorySeverity::Warning.to_string(), "warning");
187    }
188}