1use crate::impact::ImpactReport;
22use crate::recommendation::FixSuggestion;
23use crate::types::{MigrationReport, RiskLevel};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum CiFormat {
35 GithubComment,
37 GitlabComment,
39 Json,
41}
42
43impl std::str::FromStr for CiFormat {
44 type Err = String;
45 fn from_str(s: &str) -> Result<Self, Self::Err> {
46 match s {
47 "github-comment" => Ok(CiFormat::GithubComment),
48 "gitlab-comment" => Ok(CiFormat::GitlabComment),
49 "json" => Ok(CiFormat::Json),
50 other => Err(format!(
51 "unknown CI format: '{other}' — valid: github-comment, gitlab-comment, json"
52 )),
53 }
54 }
55}
56
57pub fn render_ci_report(
69 reports: &[MigrationReport],
70 fixes: &HashMap<String, Vec<FixSuggestion>>,
71 impact: Option<&ImpactReport>,
72 format: CiFormat,
73) -> String {
74 match format {
75 CiFormat::GithubComment | CiFormat::GitlabComment => {
76 render_markdown(reports, fixes, impact)
77 }
78 CiFormat::Json => serde_json::to_string_pretty(reports).unwrap_or_default(),
79 }
80}
81
82fn render_markdown(
87 reports: &[MigrationReport],
88 fixes: &HashMap<String, Vec<FixSuggestion>>,
89 impact: Option<&ImpactReport>,
90) -> String {
91 let mut md = String::with_capacity(4096);
92
93 let max_risk = reports
94 .iter()
95 .map(|r| r.overall_risk)
96 .max()
97 .unwrap_or(RiskLevel::Low);
98
99 let pg_version = reports.first().map(|r| r.pg_version).unwrap_or(14);
101 md.push_str(&format!(
102 "## 🔍 SchemaRisk — Migration Safety Report *(PostgreSQL {})*\n\n",
103 pg_version
104 ));
105
106 let banner = match max_risk {
108 RiskLevel::Critical => {
109 "> [!CAUTION]\n> 🚨 **CRITICAL RISK** — one or more migrations will cause production downtime.\n> **Do not merge without a detailed review and a maintenance window plan.**"
110 }
111 RiskLevel::High => {
112 "> [!WARNING]\n> ⚠️ **HIGH RISK** — significant impact on database availability.\n> Review all findings carefully before merging."
113 }
114 RiskLevel::Medium => {
115 "> [!IMPORTANT]\n> 🔶 **MEDIUM RISK** — deploy during a low-traffic window.\n> Some operations may briefly degrade performance."
116 }
117 RiskLevel::Low => {
118 "> [!TIP]\n> ✅ **LOW RISK** — migrations look safe to deploy."
119 }
120 };
121 md.push_str(banner);
122 md.push_str("\n\n");
123
124 md.push_str(
126 "| File | Risk | Score | Lock | Est. Duration | Breaking Changes |\n\
127 |------|:----:|------:|------|--------------:|:-----------------|\n",
128 );
129
130 for r in reports {
131 let risk_badge = risk_badge(r.overall_risk);
132 let lock_str = if r.operations.iter().any(|o| o.acquires_lock) {
134 "ACCESS EXCLUSIVE"
135 } else {
136 "—"
137 };
138 let duration = r
139 .estimated_lock_seconds
140 .map(format_duration)
141 .unwrap_or_else(|| "—".to_string());
142 let breaks = impact.map(|i| i.impacted_files.len()).unwrap_or(0);
143 let breaks_str = if breaks > 0 {
144 format!("⚠️ {} file(s)", breaks)
145 } else {
146 "—".to_string()
147 };
148 let file_short = short_name(&r.file);
149 md.push_str(&format!(
150 "| `{file_short}` | {risk_badge} | {} | `{lock_str}` | {duration} | {breaks_str} |\n",
151 r.score
152 ));
153 }
154 md.push('\n');
155
156 for r in reports {
158 render_file_section(&mut md, r, fixes, impact);
159 }
160
161 md.push_str(
163 "---\n\
164 <sub>\n\
165 🛡️ Generated by <strong><a href=\"https://github.com/Keystones-Lab/Schema-risk\">SchemaRisk</a></strong> \
166 — Prevent dangerous database migrations before they reach production.\n\n\
167 Detects lock-heavy operations • Generates safe migration plans • Scans codebase for blast radius\n\
168 </sub>\n",
169 );
170
171 md
172}
173
174fn render_file_section(
176 md: &mut String,
177 r: &MigrationReport,
178 fixes: &HashMap<String, Vec<FixSuggestion>>,
179 impact: Option<&ImpactReport>,
180) {
181 let emoji = risk_emoji(r.overall_risk);
182 md.push_str(&format!("\n### {emoji} `{}`\n\n", r.file));
183
184 if !r.affected_tables.is_empty() {
186 let tables: Vec<String> = r.affected_tables.iter().map(|t| format!("`{t}")).collect();
187 md.push_str(&format!("**Tables affected:** {}\n\n", tables.join(", ")));
188 }
189
190 if let Some(secs) = r.estimated_lock_seconds {
191 let duration = format_duration(secs);
192 let warning = if secs > 30 {
193 " — ⚠️ **This is a long lock!**"
194 } else {
195 ""
196 };
197 md.push_str(&format!(
198 "**Estimated lock duration:** {duration}{warning}\n\n"
199 ));
200 }
201
202 if !r.operations.is_empty() {
204 md.push_str("**Operations detected:**\n\n");
205 for op in &r.operations {
206 let op_emoji = if op.risk_level >= RiskLevel::High {
207 "🚨"
208 } else if op.risk_level >= RiskLevel::Medium {
209 "⚠️"
210 } else {
211 "✅"
212 };
213 md.push_str(&format!("- {op_emoji} `{}`\n", op.description));
214 if let Some(w) = &op.warning {
215 md.push_str(&format!(" > _{w}_\n"));
216 }
217 if op.acquires_lock {
218 md.push_str(" > 🔒 Acquires **ACCESS EXCLUSIVE** table lock\n");
219 }
220 }
221 md.push('\n');
222 }
223
224 if !r.warnings.is_empty() {
226 md.push_str("**Warnings:**\n\n");
227 for w in &r.warnings {
228 md.push_str(&format!("- ⚠️ {w}\n"));
229 }
230 md.push('\n');
231 }
232
233 if let Some(impact_report) = impact {
235 if !impact_report.impacted_files.is_empty() {
236 md.push_str("#### ⚠️ Breaking Changes — Codebase References\n\n");
237 md.push_str(
238 "The following files contain queries or code referencing \
239 schema objects affected by this migration:\n\n",
240 );
241 for f in impact_report.impacted_files.iter().take(15) {
242 for hit in f.hits.iter().take(3) {
243 let snippet = hit.snippet.chars().take(100).collect::<String>();
244 md.push_str(&format!(
245 "- [`{}:{}`]({}) — `{snippet}`\n",
246 f.path, hit.line, f.path
247 ));
248 }
249 }
250 if impact_report.impacted_files.len() > 15 {
251 md.push_str(&format!(
252 "\n_... and {} more files_\n",
253 impact_report.impacted_files.len() - 15
254 ));
255 }
256 md.push('\n');
257 }
258 }
259
260 let no_fixes = Vec::new();
262 let file_fixes = fixes.get(&r.file).unwrap_or(&no_fixes);
263 if !file_fixes.is_empty() {
264 md.push_str("#### ✅ Suggested Fixes\n\n");
265 for fix in file_fixes.iter().take(6) {
266 let sev_badge = match fix.severity {
267 crate::recommendation::FixSeverity::Blocking => "🚨 BLOCKING",
268 crate::recommendation::FixSeverity::Warning => "⚠️ WARNING",
269 crate::recommendation::FixSeverity::Info => "ℹ️ INFO",
270 };
271 md.push_str(&format!(
272 "**[{}] {}** `{sev_badge}`\n\n",
273 fix.rule_id, fix.title
274 ));
275 md.push_str(&format!("{}\n\n", fix.explanation));
276
277 if let Some(sql) = &fix.fixed_sql {
278 md.push_str("```sql\n");
279 md.push_str(sql);
280 md.push_str("\n```\n\n");
281 }
282
283 if let Some(steps) = &fix.migration_steps {
284 md.push_str(
285 "<details>\n<summary>📋 Zero-downtime migration steps</summary>\n\n```sql\n",
286 );
287 for step in steps {
288 md.push_str(step);
289 md.push('\n');
290 }
291 md.push_str("```\n\n</details>\n\n");
292 }
293
294 if let Some(url) = &fix.docs_url {
295 md.push_str(&format!("📖 [PostgreSQL docs]({url})\n\n"));
296 }
297 }
298 }
299
300 if !r.recommendations.is_empty() {
302 md.push_str("#### 💡 Recommendations\n\n");
303 for rec in &r.recommendations {
304 md.push_str(&format!("- {rec}\n"));
305 }
306 md.push('\n');
307 }
308
309 md.push_str(&format!(
311 "<sub>Analyzed against PostgreSQL {} rules. \
312 Use `--pg-version` to change target version.</sub>\n\n",
313 r.pg_version
314 ));
315
316 md.push_str("---\n");
317}
318
319fn risk_badge(level: RiskLevel) -> &'static str {
325 match level {
326 RiskLevel::Critical => "🚨 **CRITICAL**",
327 RiskLevel::High => "🔴 **HIGH**",
328 RiskLevel::Medium => "🟡 **MEDIUM**",
329 RiskLevel::Low => "🟢 LOW",
330 }
331}
332
333fn risk_emoji(level: RiskLevel) -> &'static str {
335 match level {
336 RiskLevel::Critical => "🚨",
337 RiskLevel::High => "🔴",
338 RiskLevel::Medium => "🟡",
339 RiskLevel::Low => "🟢",
340 }
341}
342
343fn format_duration(secs: u64) -> String {
345 if secs >= 3600 {
346 format!("~{}h {}m", secs / 3600, (secs % 3600) / 60)
347 } else if secs >= 60 {
348 format!("~{}m {}s", secs / 60, secs % 60)
349 } else {
350 format!("~{}s", secs)
351 }
352}
353
354fn short_name(path: &str) -> &str {
356 std::path::Path::new(path)
357 .file_name()
358 .and_then(|n| n.to_str())
359 .unwrap_or(path)
360}
361
362#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::types::{DetectedOperation, RiskLevel};
370
371 fn make_report(file: &str, risk: RiskLevel) -> MigrationReport {
372 MigrationReport {
373 file: file.to_string(),
374 overall_risk: risk,
375 score: risk as u32 * 25,
376 affected_tables: vec!["users".to_string()],
377 operations: vec![DetectedOperation {
378 description: "CREATE INDEX idx_users_email ON users(email)".to_string(),
379 tables: vec!["users".to_string()],
380 risk_level: risk,
381 score: risk as u32 * 25,
382 warning: Some("No CONCURRENTLY keyword".to_string()),
383 acquires_lock: true,
384 index_rebuild: true,
385 }],
386 warnings: vec!["No CONCURRENTLY".to_string()],
387 recommendations: vec!["Use CREATE INDEX CONCURRENTLY".to_string()],
388 fk_impacts: vec![],
389 estimated_lock_seconds: Some(36),
390 index_rebuild_required: true,
391 requires_maintenance_window: true,
392 analyzed_at: "2026-01-01T00:00:00Z".to_string(),
393 pg_version: 14,
394 guard_required: false,
395 guard_decisions: vec![],
396 }
397 }
398
399 #[test]
400 fn test_github_comment_contains_table_header() {
401 let reports = vec![make_report(
402 "migrations/0042_add_index.sql",
403 RiskLevel::Critical,
404 )];
405 let fixes = HashMap::new();
406 let output = render_ci_report(&reports, &fixes, None, CiFormat::GithubComment);
407 assert!(output.contains("SchemaRisk — Migration Safety Report"));
408 assert!(output.contains("PostgreSQL 14"));
409 assert!(output.contains("CRITICAL"));
410 assert!(output.contains("0042_add_index.sql"));
411 }
412
413 #[test]
414 fn test_json_output_is_valid_json() {
415 let reports = vec![make_report("test.sql", RiskLevel::Low)];
416 let fixes = HashMap::new();
417 let json = render_ci_report(&reports, &fixes, None, CiFormat::Json);
418 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
419 assert!(parsed.is_array());
420 }
421
422 #[test]
423 fn test_format_duration() {
424 assert_eq!(format_duration(0), "~0s");
425 assert_eq!(format_duration(36), "~36s");
426 assert_eq!(format_duration(90), "~1m 30s");
427 assert_eq!(format_duration(3661), "~1h 1m");
428 }
429}