1use crate::drift::DriftReport;
4use crate::graph::{SchemaEdge, SchemaGraph, SchemaNode};
5use crate::impact::ImpactReport;
6use crate::locks::{LockMode, MigrationTimeline};
7use crate::parser::ParsedStatement;
8use crate::recommendation::{FixSeverity, FixSuggestion};
9use crate::types::{DetectedOperation, MigrationReport, RiskLevel};
10use colored::Colorize;
11use petgraph::visit::EdgeRef;
12
13fn risk_color(level: RiskLevel) -> colored::ColoredString {
18 match level {
19 RiskLevel::Low => level.to_string().green().bold(),
20 RiskLevel::Medium => level.to_string().yellow().bold(),
21 RiskLevel::High => level.to_string().truecolor(255, 140, 0).bold(),
22 RiskLevel::Critical => level.to_string().red().bold(),
23 }
24}
25
26pub fn render(report: &MigrationReport, verbose: bool) {
31 let separator = "─".repeat(60);
32
33 println!("\n{}", separator.dimmed());
34 println!("{} {}", " SchemaRisk Analysis".bold(), report.file.cyan());
35 println!("{}", separator.dimmed());
36
37 println!(
39 "\n Migration Risk: {} (score: {})",
40 risk_color(report.overall_risk),
41 report.score.to_string().bold()
42 );
43
44 if !report.affected_tables.is_empty() {
46 println!(
47 "\n {} {}",
48 "Tables affected:".bold(),
49 report.affected_tables.join(", ").cyan()
50 );
51 }
52
53 if let Some(secs) = report.estimated_lock_seconds {
55 let lock_str = if secs >= 60 {
56 format!("~{} min {} sec", secs / 60, secs % 60)
57 } else {
58 format!("~{} sec", secs)
59 };
60 let colored = if secs > 30 {
61 lock_str.red()
62 } else if secs > 5 {
63 lock_str.yellow()
64 } else {
65 lock_str.green()
66 };
67 println!(" {} {}", "Estimated lock duration:".bold(), colored);
68 }
69
70 if report.index_rebuild_required {
72 println!(
73 " {} {}",
74 "Index rebuild required:".bold(),
75 "YES".red().bold()
76 );
77 }
78
79 if report.requires_maintenance_window {
81 println!(
82 " {} {}",
83 "Requires maintenance window:".bold(),
84 "YES".red().bold()
85 );
86 }
87
88 if !report.fk_impacts.is_empty() {
90 println!("\n {}:", "Foreign Key Impact".bold().underline());
91 for fk in &report.fk_impacts {
92 let cascade_note = if fk.cascade {
93 " (ON DELETE CASCADE!)".red().to_string()
94 } else {
95 String::new()
96 };
97 println!(
98 " {} {} → {}{}",
99 "•".dimmed(),
100 fk.constraint_name.yellow(),
101 fk.to_table.cyan(),
102 cascade_note
103 );
104 }
105 }
106
107 if verbose && !report.operations.is_empty() {
109 println!("\n {}:", "Detected Operations".bold().underline());
110 for op in &report.operations {
111 println!(
112 " {} [{}] {}",
113 "•".dimmed(),
114 risk_color(op.risk_level),
115 op.description
116 );
117 if op.acquires_lock {
118 println!(" {} acquires table lock", "⚠".yellow());
119 }
120 if op.index_rebuild {
121 println!(" {} triggers index rebuild", "⟳".yellow());
122 }
123 }
124 }
125
126 if !report.warnings.is_empty() {
128 println!("\n {}:", "Warnings".bold().underline());
129 for w in &report.warnings {
130 println!(" {} {}", "!".yellow().bold(), w);
131 }
132 }
133
134 if !report.recommendations.is_empty() {
136 println!("\n {}:", "Recommendations".bold().underline());
137 for r in &report.recommendations {
138 println!(" {} {}", "→".green(), r);
139 }
140 }
141
142 println!("\n{}", separator.dimmed());
143
144 let stamp = if report.requires_maintenance_window {
146 " ⛔ This migration should NOT be deployed without review".red()
147 } else if report.overall_risk >= RiskLevel::Medium {
148 " ⚠ Review recommended before deploying".yellow()
149 } else {
150 " ✓ Migration looks safe".green()
151 };
152 println!("{}\n", stamp);
153}
154
155pub fn render_statement_breakdown(stmts: &[ParsedStatement], operations: &[DetectedOperation]) {
156 let separator = "─".repeat(60);
157
158 println!("\n{}", separator.dimmed());
159 println!("{}", " Statement-by-Statement Breakdown".bold());
160 println!("{}", separator.dimmed());
161
162 for (index, stmt) in stmts.iter().enumerate() {
163 let preview = statement_preview(stmt);
164 println!("\n [{:02}] {}", index + 1, preview);
165
166 if let Some(op) = operations.get(index) {
167 if !op.description.is_empty() {
168 println!(" {} {}", "→".green(), op.description.cyan());
169 }
170 if let Some(warning) = &op.warning {
171 println!(" {} {}", "⚠".yellow(), warning.yellow());
172 }
173 }
174 }
175
176 println!("\n{}", separator.dimmed());
177}
178
179pub fn render_graph_text(graph: &SchemaGraph) {
180 let separator = "─".repeat(60);
181
182 println!("\n{}", separator.dimmed());
183 println!("{}", " Schema Dependency Graph".bold());
184 println!("{}", separator.dimmed());
185
186 let mut tables: Vec<String> = graph.table_index.keys().cloned().collect();
187 tables.sort();
188
189 if tables.is_empty() {
190 println!("\n {} No tables found in migration input.", "ℹ".cyan());
191 println!("\n{}", separator.dimmed());
192 return;
193 }
194
195 println!("\n {}", "Tables".bold().underline());
196 for table in &tables {
197 println!(" {} {}", "•".dimmed(), table.cyan());
198 }
199
200 let mut fk_lines: Vec<String> = Vec::new();
201 for edge in graph.graph.edge_references() {
202 if let SchemaEdge::ForeignKey {
203 constraint_name,
204 from_columns,
205 to_columns,
206 cascade_delete,
207 ..
208 } = edge.weight()
209 {
210 let Some(SchemaNode::Table {
211 name: from_table, ..
212 }) = graph.graph.node_weight(edge.source())
213 else {
214 continue;
215 };
216 let Some(SchemaNode::Table { name: to_table, .. }) =
217 graph.graph.node_weight(edge.target())
218 else {
219 continue;
220 };
221
222 let from_col = from_columns.first().map(String::as_str).unwrap_or("*");
223 let to_col = to_columns.first().map(String::as_str).unwrap_or("*");
224 let fk_name = constraint_name.as_deref().unwrap_or("unnamed_fk");
225 let cascade_note = if *cascade_delete {
226 " [ON DELETE CASCADE]"
227 } else {
228 ""
229 };
230
231 fk_lines.push(format!(
232 "{}.{from_col} → {}.{to_col} ({fk_name}){cascade_note}",
233 from_table, to_table
234 ));
235 }
236 }
237 fk_lines.sort();
238
239 println!("\n {}", "Foreign keys".bold().underline());
240 if fk_lines.is_empty() {
241 println!(" {} none", "•".dimmed());
242 } else {
243 for relation in fk_lines {
244 println!(" {} {}", "•".dimmed(), relation);
245 }
246 }
247
248 println!(
249 "\n {} {}",
250 "Total tables:".bold(),
251 tables.len().to_string().cyan()
252 );
253 println!("{}\n", separator.dimmed());
254}
255
256fn statement_preview(stmt: &ParsedStatement) -> String {
257 match stmt {
258 ParsedStatement::CreateTable { table, .. } => format!("CREATE TABLE {}", table),
259 ParsedStatement::DropTable { tables, .. } => format!("DROP TABLE {}", tables.join(", ")),
260 ParsedStatement::AlterTableAddColumn { table, column } => {
261 format!(
262 "ALTER TABLE {} ADD COLUMN {} {}",
263 table, column.name, column.data_type
264 )
265 }
266 ParsedStatement::AlterTableDropColumn { table, column, .. } => {
267 format!("ALTER TABLE {} DROP COLUMN {}", table, column)
268 }
269 ParsedStatement::AlterTableAlterColumnType {
270 table,
271 column,
272 new_type,
273 } => format!(
274 "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
275 table, column, new_type
276 ),
277 ParsedStatement::AlterTableSetNotNull { table, column } => {
278 format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", table, column)
279 }
280 ParsedStatement::AlterTableAddForeignKey { table, fk } => {
281 let from_cols = if fk.columns.is_empty() {
282 "*".to_string()
283 } else {
284 fk.columns.join(", ")
285 };
286 let to_cols = if fk.ref_columns.is_empty() {
287 "*".to_string()
288 } else {
289 fk.ref_columns.join(", ")
290 };
291 format!(
292 "ALTER TABLE {} ADD FOREIGN KEY ({}) REFERENCES {} ({})",
293 table, from_cols, fk.ref_table, to_cols
294 )
295 }
296 ParsedStatement::AlterTableDropConstraint {
297 table, constraint, ..
298 } => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, constraint),
299 ParsedStatement::AlterTableRenameColumn { table, old, new } => {
300 format!("ALTER TABLE {} RENAME COLUMN {} TO {}", table, old, new)
301 }
302 ParsedStatement::AlterTableRenameTable { old, new } => {
303 format!("ALTER TABLE {} RENAME TO {}", old, new)
304 }
305 ParsedStatement::CreateIndex {
306 index_name,
307 table,
308 columns,
309 unique,
310 concurrently,
311 } => {
312 let name = index_name.as_deref().unwrap_or("<unnamed>");
313 let unique_str = if *unique { "UNIQUE " } else { "" };
314 let concurrently_str = if *concurrently { " CONCURRENTLY" } else { "" };
315 format!(
316 "CREATE {}INDEX{} {} ON {} ({})",
317 unique_str,
318 concurrently_str,
319 name,
320 table,
321 columns.join(", ")
322 )
323 }
324 ParsedStatement::DropIndex { names, .. } => format!("DROP INDEX {}", names.join(", ")),
325 ParsedStatement::AlterTableAddPrimaryKey { table, columns } => {
326 format!(
327 "ALTER TABLE {} ADD PRIMARY KEY ({})",
328 table,
329 columns.join(", ")
330 )
331 }
332 ParsedStatement::AlterTableDropPrimaryKey { table } => {
333 format!("ALTER TABLE {} DROP PRIMARY KEY", table)
334 }
335 ParsedStatement::AlterTableAlterColumnDefault {
336 table,
337 column,
338 drop_default,
339 } => {
340 if *drop_default {
341 format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", table, column)
342 } else {
343 format!(
344 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT ...",
345 table, column
346 )
347 }
348 }
349 ParsedStatement::Reindex {
350 target_type,
351 target_name,
352 concurrently,
353 } => {
354 let conc = if *concurrently { " CONCURRENTLY" } else { "" };
355 format!("REINDEX{} {} {}", conc, target_type, target_name)
356 }
357 ParsedStatement::Cluster { table, index } => match (table, index) {
358 (Some(t), Some(i)) => format!("CLUSTER {} USING {}", t, i),
359 (Some(t), None) => format!("CLUSTER {}", t),
360 _ => "CLUSTER".to_string(),
361 },
362 ParsedStatement::Truncate { tables, cascade } => {
363 let cascade_str = if *cascade { " CASCADE" } else { "" };
364 format!("TRUNCATE TABLE {}{}", tables.join(", "), cascade_str)
365 }
366 ParsedStatement::Other { raw } => {
367 let trimmed = raw.trim();
368 if trimmed.len() > 100 {
369 format!("{}…", &trimmed[..99])
370 } else {
371 trimmed.to_string()
372 }
373 }
374 }
375}
376
377fn table_preset() -> &'static str {
386 let unicode_ok = std::env::var("TERM")
387 .map(|t| t.contains("xterm") || t.contains("rxvt") || t.contains("screen"))
388 .unwrap_or(false)
389 || cfg!(target_os = "linux")
390 || cfg!(target_os = "macos");
391
392 if unicode_ok {
393 comfy_table::presets::UTF8_FULL_CONDENSED
394 } else {
395 comfy_table::presets::ASCII_FULL
396 }
397}
398
399pub fn render_summary_table(reports: &[MigrationReport]) {
405 use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
406
407 println!();
408 let mut table = Table::new();
409 table
410 .load_preset(table_preset())
411 .set_content_arrangement(ContentArrangement::Dynamic)
412 .set_header(vec![
413 Cell::new("File").add_attribute(Attribute::Bold),
414 Cell::new("Risk").add_attribute(Attribute::Bold),
415 Cell::new("Score")
416 .add_attribute(Attribute::Bold)
417 .set_alignment(CellAlignment::Right),
418 Cell::new("Lock Duration")
419 .add_attribute(Attribute::Bold)
420 .set_alignment(CellAlignment::Right),
421 Cell::new("Maint. Window").add_attribute(Attribute::Bold),
422 Cell::new("Tables").add_attribute(Attribute::Bold),
423 ]);
424
425 for r in reports {
426 let (risk_text, risk_color) = match r.overall_risk {
427 RiskLevel::Critical => ("CRITICAL", Color::Red),
428 RiskLevel::High => ("HIGH", Color::Yellow),
429 RiskLevel::Medium => ("MEDIUM", Color::Cyan),
430 RiskLevel::Low => ("LOW", Color::Green),
431 };
432 let duration = r
433 .estimated_lock_seconds
434 .map(|s| {
435 if s >= 60 {
436 format!("~{}m {}s", s / 60, s % 60)
437 } else {
438 format!("~{}s", s)
439 }
440 })
441 .unwrap_or_else(|| "—".to_string());
442 let window = if r.requires_maintenance_window {
443 Cell::new("YES")
444 .fg(Color::Red)
445 .add_attribute(Attribute::Bold)
446 } else {
447 Cell::new("no").fg(Color::Green)
448 };
449 let tables_str = if r.affected_tables.is_empty() {
450 "—".to_string()
451 } else {
452 r.affected_tables
453 .iter()
454 .take(3)
455 .cloned()
456 .collect::<Vec<_>>()
457 .join(", ")
458 + if r.affected_tables.len() > 3 {
459 " …"
460 } else {
461 ""
462 }
463 };
464 table.add_row(vec![
465 Cell::new(shorten(&r.file, 40)),
466 Cell::new(risk_text)
467 .fg(risk_color)
468 .add_attribute(Attribute::Bold),
469 Cell::new(r.score.to_string()).set_alignment(CellAlignment::Right),
470 Cell::new(duration).set_alignment(CellAlignment::Right),
471 window,
472 Cell::new(tables_str),
473 ]);
474 }
475
476 println!("{table}");
477
478 let max_risk = reports
479 .iter()
480 .map(|r| r.overall_risk)
481 .max()
482 .unwrap_or(RiskLevel::Low);
483 println!(
484 "\n Highest risk across all files: {}\n",
485 risk_color(max_risk)
486 );
487}
488
489fn shorten(s: &str, max: usize) -> String {
490 if s.len() <= max {
491 s.to_string()
492 } else {
493 format!("…{}", &s[s.len().saturating_sub(max - 1)..])
494 }
495}
496
497pub fn render_timeline(timeline: &MigrationTimeline) {
502 let sep = "─".repeat(70);
503 println!("\n{}", sep.dimmed());
504 println!(" {}", "Lock Simulation & Migration Timeline".bold());
505 println!("{}", sep.dimmed());
506
507 println!("\n Lock Risk: {}", risk_color(timeline.lock_risk));
508 println!(
509 " Total duration: ~{} sec",
510 timeline.total_secs.to_string().cyan()
511 );
512 println!(
513 " Max lock hold: {} sec",
514 if timeline.max_lock_hold_secs > 30 {
515 timeline.max_lock_hold_secs.to_string().red().bold()
516 } else if timeline.max_lock_hold_secs > 5 {
517 timeline.max_lock_hold_secs.to_string().yellow()
518 } else {
519 timeline.max_lock_hold_secs.to_string().green()
520 }
521 );
522
523 if !timeline.lock_events.is_empty() {
525 println!("\n {}:", "Operations and their locks".bold().underline());
526 println!(
527 " {:<45} {:<26} {:<8} {}",
528 "Statement".dimmed(),
529 "Lock Mode".dimmed(),
530 "Hold(s)".dimmed(),
531 "Impact".dimmed()
532 );
533 println!(" {}", "·".repeat(110).dimmed());
534
535 for ev in &timeline.lock_events {
536 let stmt = shorten(&ev.statement, 44);
537 let lock_str = lock_mode_color(ev.lock_mode);
538 let hold_str = if ev.estimated_hold_secs > 30 {
539 ev.estimated_hold_secs.to_string().red().bold()
540 } else if ev.estimated_hold_secs > 5 {
541 ev.estimated_hold_secs.to_string().yellow()
542 } else {
543 ev.estimated_hold_secs.to_string().green()
544 };
545
546 println!(
547 " {:<45} {:<35} {:<8} {}",
548 stmt, lock_str, hold_str, ev.impact
549 );
550
551 if let Some(alt) = &ev.safe_alternative {
552 println!(
553 " {} {}",
554 "Safe alternative:".green().bold(),
555 alt.lines().next().unwrap_or(alt)
556 );
557 for extra_line in alt.lines().skip(1) {
558 println!(" {}", extra_line.dimmed());
559 }
560 }
561 }
562 }
563
564 println!("\n {}:", "Execution timeline".bold().underline());
566 for step in &timeline.steps {
567 let lock_badge = match step.lock {
568 Some(LockMode::AccessExclusive) => " [LOCKED: reads+writes]".red().to_string(),
569 Some(LockMode::Share) => " [LOCKED: writes only]".yellow().to_string(),
570 Some(LockMode::ShareUpdateExclusive) => {
571 " [LOCK: allows reads+writes]".cyan().to_string()
572 }
573 Some(m) => format!(" [{}]", m.name()).dimmed().to_string(),
574 None => String::new(),
575 };
576 println!(
577 " {:>6}s {}{}",
578 step.offset_secs,
579 step.event.dimmed(),
580 lock_badge
581 );
582 }
583
584 println!("\n{}", sep.dimmed());
585}
586
587fn lock_mode_color(mode: LockMode) -> colored::ColoredString {
588 match mode {
589 LockMode::AccessExclusive => mode.name().red().bold(),
590 LockMode::Exclusive => mode.name().red(),
591 LockMode::ShareRowExclusive | LockMode::Share => mode.name().yellow(),
592 LockMode::ShareUpdateExclusive => mode.name().cyan(),
593 _ => mode.name().green(),
594 }
595}
596
597pub fn render_impact(report: &ImpactReport) {
602 let sep = "─".repeat(60);
603 println!("\n{}", sep.dimmed());
604 println!("{}", " SchemaRisk Impact Report".bold());
605 println!("{}", sep.dimmed());
606
607 println!(
608 "\n Files scanned: {}",
609 report.files_scanned.to_string().cyan()
610 );
611 println!(
612 " Impacted files: {}",
613 if report.impacted_files.is_empty() {
614 "0 (none found)".green().to_string()
615 } else {
616 report
617 .impacted_files
618 .len()
619 .to_string()
620 .yellow()
621 .bold()
622 .to_string()
623 }
624 );
625
626 if report.impacted_files.is_empty() {
627 println!(
628 "\n {} No source files reference the affected schema objects.",
629 "✓".green()
630 );
631 } else {
632 println!("\n {}:", "Impacted files".bold().underline());
633 for f in &report.impacted_files {
634 println!("\n {}", f.path.yellow().bold());
635 if !f.tables_referenced.is_empty() {
636 println!(" Tables: {}", f.tables_referenced.join(", ").cyan());
637 }
638 if !f.columns_referenced.is_empty() {
639 println!(" Columns: {}", f.columns_referenced.join(", ").cyan());
640 }
641 for hit in f.hits.iter().take(5) {
643 println!(
644 " {:>5}: {}",
645 format!("L{}", hit.line).dimmed(),
646 hit.snippet.dimmed()
647 );
648 }
649 if f.hits.len() > 5 {
650 println!(
651 " {} more matches…",
652 (f.hits.len() - 5).to_string().dimmed()
653 );
654 }
655 }
656 }
657
658 println!("{}", sep.dimmed());
659}
660
661pub fn render_drift(report: &DriftReport) {
666 let sep = "─".repeat(60);
667 println!("\n{}", sep.dimmed());
668 println!("{}", " SchemaRisk Drift Report".bold());
669 println!("{}", sep.dimmed());
670
671 if report.in_sync {
672 println!(
673 "\n {} Schema is in sync — no drift detected.\n",
674 "✓".green().bold()
675 );
676 println!("{}", sep.dimmed());
677 return;
678 }
679
680 println!("\n Overall drift: {}", risk_color(report.overall_drift));
681 println!(
682 " Total findings: {}\n",
683 report.total_findings.to_string().red().bold()
684 );
685
686 for (label, severity, bullet_str) in [
688 ("CRITICAL", RiskLevel::Critical, "✗"),
689 ("HIGH", RiskLevel::High, "!"),
690 ("MEDIUM", RiskLevel::Medium, "·"),
691 ("LOW", RiskLevel::Low, "·"),
692 ] {
693 let items: Vec<_> = report
694 .findings
695 .iter()
696 .filter(|f| f.severity() == severity)
697 .collect();
698 if items.is_empty() {
699 continue;
700 }
701 let label_colored = match severity {
702 RiskLevel::Critical => label.red().bold().to_string(),
703 RiskLevel::High => label.truecolor(255, 140, 0).bold().to_string(),
704 RiskLevel::Medium => label.yellow().to_string(),
705 _ => label.dimmed().to_string(),
706 };
707 println!(" {}:", label_colored);
708 for finding in items {
709 let bullet = match severity {
710 RiskLevel::Critical => bullet_str.red().to_string(),
711 RiskLevel::High => bullet_str.yellow().to_string(),
712 _ => bullet_str.dimmed().to_string(),
713 };
714 println!(" {} {}", bullet, finding.description());
715 }
716 println!();
717 }
718
719 println!("{}", sep.dimmed());
720}
721
722pub fn render_fix_suggestions(fixes: &[FixSuggestion]) {
728 use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
729
730 let sep = "─".repeat(60);
731 println!("\n{}", sep.dimmed());
732 println!("{}", " SchemaRisk Fix Suggestions".bold());
733 println!("{}", sep.dimmed());
734
735 let mut table = Table::new();
737 table
738 .load_preset(table_preset())
739 .set_content_arrangement(ContentArrangement::Dynamic)
740 .set_header(vec![
741 Cell::new("ID").add_attribute(Attribute::Bold),
742 Cell::new("Severity").add_attribute(Attribute::Bold),
743 Cell::new("Title").add_attribute(Attribute::Bold),
744 Cell::new("Auto-Fix").add_attribute(Attribute::Bold),
745 ]);
746
747 for fix in fixes {
748 let (sev_text, sev_color) = match fix.severity {
749 FixSeverity::Blocking => ("BLOCKING", Color::Red),
750 FixSeverity::Warning => ("WARNING", Color::Yellow),
751 FixSeverity::Info => ("INFO", Color::Cyan),
752 };
753 let auto_fix = if fix.auto_fixable {
754 Cell::new("yes").fg(Color::Green)
755 } else {
756 Cell::new("manual").fg(Color::Yellow)
757 };
758 table.add_row(vec![
759 Cell::new(&fix.rule_id),
760 Cell::new(sev_text)
761 .fg(sev_color)
762 .add_attribute(Attribute::Bold),
763 Cell::new(&fix.title),
764 auto_fix,
765 ]);
766 }
767 println!("{table}");
768
769 for fix in fixes {
771 let severity_badge = match fix.severity {
772 FixSeverity::Blocking => format!("[{}]", "BLOCKING".red().bold()),
773 FixSeverity::Warning => format!("[{}]", "WARNING".yellow().bold()),
774 FixSeverity::Info => format!("[{}]", "INFO".cyan()),
775 };
776
777 println!(
778 "\n {} {} {}",
779 fix.rule_id.bold(),
780 severity_badge,
781 fix.title.bold()
782 );
783 println!();
784 for chunk in wrap_text(&fix.explanation, 72) {
786 println!(" {chunk}");
787 }
788 println!();
789
790 if let Some(sql) = &fix.fixed_sql {
791 println!(" {}", "Fixed SQL:".green().bold());
792 for line in sql.lines() {
793 println!(" {}", line.green());
794 }
795 println!();
796 }
797
798 if let Some(steps) = &fix.migration_steps {
799 println!(" {}", "Migration steps:".cyan().bold());
800 for step in steps {
801 if step.is_empty() {
802 println!();
803 } else {
804 println!(" {}", step.dimmed());
805 }
806 }
807 println!();
808 }
809
810 if let Some(url) = &fix.docs_url {
811 println!(" {} {}", "Docs:".dimmed(), url.dimmed());
812 println!();
813 }
814
815 println!("{}", sep.dimmed());
816 }
817}
818
819fn wrap_text(text: &str, width: usize) -> Vec<String> {
821 let mut lines = Vec::new();
822 for paragraph in text.split("\n") {
823 let mut line = String::new();
824 for word in paragraph.split_whitespace() {
825 if line.is_empty() {
826 line.push_str(word);
827 } else if line.len() + 1 + word.len() <= width {
828 line.push(' ');
829 line.push_str(word);
830 } else {
831 lines.push(line.clone());
832 line = word.to_string();
833 }
834 }
835 if !line.is_empty() {
836 lines.push(line);
837 }
838 }
839 lines
840}