diff --git a/cot-core/src/error/error_impl.rs b/cot-core/src/error/error_impl.rs index 80a2f0f3a..2931143f0 100644 --- a/cot-core/src/error/error_impl.rs +++ b/cot-core/src/error/error_impl.rs @@ -1,5 +1,6 @@ use std::error::Error as StdError; use std::fmt::Display; +use std::io; use std::ops::Deref; use derive_more::with_trait::Debug; @@ -291,6 +292,8 @@ impl From for Error { } } +impl_into_cot_error!(io::Error); + #[cfg(test)] mod tests { use derive_more::with_trait::Debug; @@ -301,11 +304,11 @@ mod tests { #[derive(Debug, thiserror::Error)] #[error("outer error")] - struct OuterError(#[source] std::io::Error); + struct OuterError(#[source] io::Error); #[test] fn error_new() { - let inner = std::io::Error::other("server error"); + let inner = io::Error::other("server error"); let error = Error::wrap(inner); assert!(StdError::source(&error).is_none()); @@ -314,7 +317,7 @@ mod tests { #[test] fn error_display() { - let inner = std::io::Error::other("server error"); + let inner = io::Error::other("server error"); let error = Error::internal(inner); let display = format!("{error}"); @@ -324,7 +327,7 @@ mod tests { #[test] fn error_wrap_and_is_wrapper() { - let inner = std::io::Error::other("wrapped"); + let inner = io::Error::other("wrapped"); let error = Error::wrap(inner); assert!(error.is_wrapper()); @@ -375,7 +378,7 @@ mod tests { #[test] fn error_from_template_render() { - let askama_err = askama::Error::Custom(Box::new(std::io::Error::other("fail"))); + let askama_err = askama::Error::Custom(Box::new(io::Error::other("fail"))); let error: Error = askama_err.into(); assert!(error.to_string().contains("failed to render template")); @@ -407,10 +410,10 @@ mod tests { let err = Error::with_status("root error", StatusCode::BAD_REQUEST); assert_snapshot!(format!("{err:?}"), @"root error"); - let err = Error::wrap(std::io::Error::other("io error")); + let err = Error::wrap(io::Error::other("io error")); assert_snapshot!(format!("{err:?}"), @"io error"); - let io_err = std::io::Error::other("inner io error"); + let io_err = io::Error::other("inner io error"); let err = Error::wrap(OuterError(io_err)); assert_snapshot!(format!("{err:?}"), @r###" outer error @@ -419,7 +422,7 @@ mod tests { 0: inner io error "###); - let err = Error::internal(OuterError(std::io::Error::other("inner io error"))); + let err = Error::internal(OuterError(io::Error::other("inner io error"))); assert_snapshot!(format!("{err:?}"), @r###" outer error @@ -438,9 +441,7 @@ mod tests { #[error("wrapper error")] struct WrapperError(#[source] OuterError); - let err = Error::internal(WrapperError(OuterError(std::io::Error::other( - "inner io error", - )))); + let err = Error::internal(WrapperError(OuterError(io::Error::other("inner io error")))); assert_snapshot!(format!("{err:?}"), @" wrapper error @@ -458,7 +459,7 @@ mod tests { )] fn error_debug_printing_alternate() { let err = Error::with_status( - OuterError(std::io::Error::other("inner io error")), + OuterError(io::Error::other("inner io error")), StatusCode::INTERNAL_SERVER_ERROR, ); assert_snapshot!(format!("{err:#?}"), @r#" diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 1c7232ccc..8563dcf57 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; pub use clap; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; #[cfg(feature = "db")] -use cot::db::migrations::{MigrationEngine, SyncDynMigration}; +use cot::db::migrations::{GraphExporter, GraphFormat, MigrationEngine, SyncDynMigration}; use cot::project::BootstrappedProject; use derive_more::Debug; @@ -21,6 +21,7 @@ const LISTEN_PARAM: &str = "listen"; const COLLECT_STATIC_DIR_PARAM: &str = "dir"; const MIGRATION_GROUP_SUBCOMMAND: &str = "migration"; const MIGRATION_ROLLBACK_SUBCOMMAND: &str = "rollback"; +const MIGRATION_GRAPH_SUBCOMMAND: &str = "graph"; /// A central point for configuring the default Command Line Interface (CLI) for /// Cot-powered projects. @@ -101,6 +102,7 @@ impl Cli { let mut migration_group = CliTaskGroup::new(MIGRATION_GROUP_SUBCOMMAND).about("Database migration commands"); migration_group.add_task(MigrationRollback); + migration_group.add_task(MigrationGraph); cli.add_task(migration_group); } @@ -655,6 +657,75 @@ impl CliTask for MigrationRollback { } } +#[cfg(feature = "db")] +struct MigrationGraph; + +#[cfg(feature = "db")] +#[async_trait(?Send)] +impl CliTask for MigrationGraph { + fn subcommand(&self) -> Command { + Command::new(MIGRATION_GRAPH_SUBCOMMAND) + .about("Export the migration dependency graph for visualization") + .arg( + Arg::new("format") + .long("format") + .value_name("FORMAT") + .value_parser(["dot", "mermaid"]) + .default_value("dot") + .help("Output format: dot (Graphviz) or mermaid"), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .value_name("FILE") + .value_parser(value_parser!(PathBuf)) + .required(false) + .help("Write to a file instead of stdout"), + ) + } + + async fn execute( + &mut self, + matches: &ArgMatches, + bootstrapper: Bootstrapper, + ) -> Result<()> { + let format = match matches.get_one::("format").map(String::as_str) { + Some("mermaid") => GraphFormat::Mermaid, + _ => GraphFormat::Dot, + }; + + let bootstrapper = bootstrapper + .with_apps() + .with_database() + .await? + .boot() + .await?; + + let BootstrappedProject { + context, + handler: _, + error_handler: _, + } = bootstrapper.finish(); + + let mut migrations: Vec> = Vec::new(); + for app in context.apps() { + migrations.extend(app.migrations()); + } + + let engine = MigrationEngine::new(migrations)?; + let exporter = GraphExporter::new(engine.migrations()); + let rendered = exporter.export(format)?; + + match matches.get_one::("output") { + Some(path) => std::fs::write(path, rendered)?, + None => println!("{rendered}"), + } + + Ok(()) + } +} + /// A macro to generate a [`CliMetadata`] struct from the Cargo manifest. #[macro_export] macro_rules! metadata { diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 126c28542..223c9befc 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -1,5 +1,6 @@ //! Database migrations. +mod graph_export; mod sorter; use std::collections::{HashSet, VecDeque}; @@ -9,6 +10,7 @@ use std::io::Write; use std::{fmt, io}; pub use cot_macros::migration_op; +pub(crate) use graph_export::{GraphExporter, GraphFormat}; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -486,6 +488,10 @@ impl MigrationEngine { .await?; Ok(()) } + + pub(crate) fn migrations(&self) -> &[MigrationWrapper] { + &self.migrations + } } /// Resolves the possible migration names that can be used to refer to a diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs new file mode 100644 index 000000000..8c18dd3e0 --- /dev/null +++ b/cot/src/db/migrations/graph_export.rs @@ -0,0 +1,378 @@ +//! Rendering of the migration dependency graph for external visualization +//! tools (Graphviz `dot`, Mermaid). +mod dot; +mod mermaid; + +use std::collections::HashMap; + +use cot::db::migrations::MigrationEngineError; + +use crate::db::migrations::DynMigration; +use crate::db::migrations::sorter::MigrationSorter; + +/// The output format for a rendered migration dependency graph. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub(crate) enum GraphFormat { + /// [Graphviz DOT](https://graphviz.org/doc/info/lang.html) format. + Dot, + /// [Mermaid](https://mermaid.js.org/syntax/flowchart.html) flowchart syntax. + Mermaid, +} + +mod style { + /// Node fill color. + pub(super) const NODE_FILL: &str = "#eef2ff"; + /// Node border color. + pub(super) const NODE_STROKE: &str = "#4c51bf"; + /// Node label text color. + pub(super) const NODE_TEXT: &str = "#1e1b4b"; + + /// Cluster (app group) fill color. + pub(super) const CLUSTER_FILL: &str = "#f9fafb"; + /// Cluster border color. + pub(super) const CLUSTER_STROKE: &str = "#d1d5db"; + /// Cluster title text color. + pub(super) const CLUSTER_TEXT: &str = "#374151"; + + /// Edge/arrow color. + pub(super) const EDGE_COLOR: &str = "#9aa5b1"; + + /// Font family used for node and cluster labels (DOT only; Mermaid picks + /// up the surrounding theme's font). + pub(super) const FONT_FAMILY: &str = "Helvetica,Arial,sans-serif"; + + /// Maximum label line length before we wrap to the next line. + pub(super) const LABEL_WRAP_WIDTH: usize = 16; +} + +struct Node<'a> { + id: String, + app: &'a str, + label: &'a str, +} + +pub(crate) struct GraphExporter<'a, T> { + migrations: &'a [T], +} + +impl<'a, T: DynMigration> GraphExporter<'a, T> { + pub(crate) fn new(migrations: &'a [T]) -> Self { + Self { migrations } + } + pub(crate) fn export(&self, format: GraphFormat) -> super::Result { + let graph = MigrationSorter::generate_graph(self.migrations).map_err(|e| { + MigrationEngineError::Custom(format!("Failed to generate migration graph: {e}")) + })?; + + let nodes = self + .migrations + .iter() + .enumerate() + .map(|(i, m)| Node { + id: format!("n{i}"), + app: m.app_name(), + label: m.name(), + }) + .collect::>(); + + Ok(match format { + GraphFormat::Dot => dot::render(&nodes, &graph), + GraphFormat::Mermaid => mermaid::render(&nodes, &graph), + }) + } +} + +fn wrap_label(label: &str) -> Vec { + #[allow(clippy::allow_attributes, clippy::wildcard_imports)] + use style::*; + + if label.len() <= LABEL_WRAP_WIDTH { + return vec![label.to_owned()]; + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for segment in label.split('_') { + let candidate_len = if current.is_empty() { + segment.len() + } else { + current.len() + 1 + segment.len() + }; + + if candidate_len > LABEL_WRAP_WIDTH && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + + if !current.is_empty() { + current.push('_'); + } + current.push_str(segment); + + if current.len() > LABEL_WRAP_WIDTH { + lines.push(std::mem::take(&mut current)); + } + } + + if !current.is_empty() { + lines.push(current); + } + + lines +} + +fn group_by_app<'a>(nodes: &[Node<'a>]) -> Vec<(&'a str, Vec)> { + let mut groups: HashMap<&str, Vec> = HashMap::new(); + + for (i, node) in nodes.iter().enumerate() { + groups.entry(node.app).or_default().push(i); + } + let mut ord = groups.into_iter().collect::>(); + ord.sort(); + ord +} + +#[cfg(test)] +mod tests { + use cot::auth::db::DatabaseUserApp; + use cot::db::migrations::{ + Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, + wrap_migrations, + }; + use cot::db::{DatabaseField, Identifier}; + use cot::session::db::SessionApp; + use style::*; + + use super::*; + use crate::App; + use crate::db::migrations::MigrationWrapper; + use crate::test::TestMigration; + + const SNAPSHOT_RELATIVE_PATH: &str = "../../../tests/db_testing/snapshots/migrations"; + + struct App1Initial; + + impl Migration for App1Initial { + const APP_NAME: &'static str = "app1"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("single__first")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct App10002; + + impl Migration for App10002 { + const APP_NAME: &'static str = "app1"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = + &[MigrationDependency::migration("app1", "m_0001_initial")]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("app1__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct App1003; + + impl Migration for App1003 { + const APP_NAME: &'static str = "app1"; + const MIGRATION_NAME: &'static str = "m_0003_third"; + const DEPENDENCIES: &'static [MigrationDependency] = + &[MigrationDependency::migration("app1", "m_0002_second")]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("single__third")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct App2Initial; + + impl Migration for App2Initial { + const APP_NAME: &'static str = "app2"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("app2__foo")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct DependentInitial; + + impl Migration for DependentInitial { + const APP_NAME: &'static str = "dependent"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = + &[MigrationDependency::migration("app1", "m_0002_second")]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("dependent__bar")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + fn render_mermaid(migrations: &[MigrationWrapper]) -> String { + let exporter = GraphExporter::new(migrations); + exporter.export(GraphFormat::Mermaid).unwrap() + } + + fn render_dot(migrations: &[MigrationWrapper]) -> String { + let exporter = GraphExporter::new(migrations); + exporter.export(GraphFormat::Dot).unwrap() + } + + fn wrap(migrations: Vec) -> Vec { + migrations.into_iter().map(MigrationWrapper::new).collect() + } + + #[test] + fn wrap_label_short_label_unchanged() { + assert_eq!(wrap_label("m_0001_initial"), vec!["m_0001_initial"]); + } + + #[test] + fn wrap_label_long_label_splits_on_underscore() { + let lines = wrap_label("m_0002_auto_20260527_004236"); + assert!(lines.len() > 1); + assert!(lines.iter().all(|l| l.len() <= LABEL_WRAP_WIDTH + 8)); + assert_eq!(lines.join("_"), "m_0002_auto_20260527_004236"); + } + + #[test] + fn dot_render_empty_migrations() { + let migrations: Vec = Vec::new(); + let exporter = GraphExporter::new(&migrations); + let dot = exporter.export(GraphFormat::Dot).unwrap(); + + assert!(dot.starts_with("digraph migrations {")); + assert!(dot.trim_end().ends_with('}')); + assert!(!dot.contains("subgraph")); + assert!(!dot.contains("->")); + } + + #[test] + fn mermaid_render_empty_migrations() { + let migrations: Vec = Vec::new(); + let exporter = GraphExporter::new(&migrations); + let mermaid = exporter.export(GraphFormat::Mermaid).unwrap(); + + assert!(mermaid.contains("flowchart LR")); + assert!(!mermaid.contains("subgraph")); + assert!(!mermaid.contains("-->")); + assert!(!mermaid.contains("n0")); + } + + #[test] + fn render_dispatches_dot_vs_mermaid() { + let migrations = wrap(vec![TestMigration::new("app", "m1", [], [])]); + let exporter = GraphExporter::new(&migrations); + let dot = exporter.export(GraphFormat::Dot).unwrap(); + let mermaid = exporter.export(GraphFormat::Mermaid).unwrap(); + + assert!(dot.contains("digraph migrations")); + assert!(!dot.contains("flowchart")); + assert!(mermaid.contains("flowchart LR")); + assert!(!mermaid.contains("digraph")); + } + + #[test] + #[cfg_attr( + miri, + ignore = "unsupported operation: socketpair: type 0x5 is unsupported, only SOCK_STREAM, SOCK_CLOEXEC and SOCK_NONBLOCK are allowed" + )] + fn test_migration_graph_single_app() { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &App1Initial as &SyncDynMigration, + &App10002 as &SyncDynMigration, + &App1003 as &SyncDynMigration, + ]) + .unwrap(); + let dot = render_dot(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_single_app", dot); + }); + + let mermaid = render_mermaid(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_single_app", mermaid); + }); + } + + #[test] + #[cfg_attr( + miri, + ignore = "unsupported operation: socketpair: type 0x5 is unsupported, only SOCK_STREAM, SOCK_CLOEXEC and SOCK_NONBLOCK are allowed" + )] + fn test_migration_graph_unrelated_apps() { + let mut migrations = DatabaseUserApp::new().migrations(); + + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &App1Initial as &SyncDynMigration, + &App10002 as &SyncDynMigration, + &App2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + + let engine = MigrationEngine::new(migrations).unwrap(); + + let dot = render_dot(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_unrelated_apps", dot); + }); + + let mermaid = render_mermaid(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_unrelated_apps", mermaid); + }); + } + + #[test] + #[cfg_attr( + miri, + ignore = "unsupported operation: socketpair: type 0x5 is unsupported, only SOCK_STREAM, SOCK_CLOEXEC and SOCK_NONBLOCK are allowed" + )] + fn test_migration_graph_dependent_apps() { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &App1Initial as &SyncDynMigration, + &App10002 as &SyncDynMigration, + &DependentInitial as &SyncDynMigration, + &App2Initial as &SyncDynMigration, + ]) + .unwrap(); + let dot = render_dot(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_dependent_apps", dot); + }); + + let mermaid = render_mermaid(engine.migrations()); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_dependent_apps", mermaid); + }); + } +} diff --git a/cot/src/db/migrations/graph_export/dot.rs b/cot/src/db/migrations/graph_export/dot.rs new file mode 100644 index 000000000..3dc87763c --- /dev/null +++ b/cot/src/db/migrations/graph_export/dot.rs @@ -0,0 +1,278 @@ +use std::fmt::Write; + +use super::{Node, group_by_app, wrap_label}; +use crate::utils::graph::Graph; + +pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { + #[allow(clippy::allow_attributes, clippy::wildcard_imports)] + use super::style::*; + + let mut out = String::new(); + let _ = writeln!(out, "digraph migrations {{"); + let _ = writeln!(out, " rankdir=LR;"); + let _ = writeln!(out, " splines=spline;"); + let _ = writeln!(out, " nodesep=0.4;"); + let _ = writeln!(out, " ranksep=0.6;"); + let _ = writeln!(out, " bgcolor=\"transparent\";\n"); + + let _ = writeln!(out, " graph [fontname=\"{FONT_FAMILY}\"];"); + let _ = writeln!(out, " node [fontname=\"{FONT_FAMILY}\", fontsize=11];"); + let _ = writeln!(out, " edge [fontname=\"{FONT_FAMILY}\", fontsize=9];\n"); + + let _ = writeln!(out, " node ["); + let _ = writeln!(out, " shape=box,"); + let _ = writeln!(out, " style=\"rounded,filled\","); + let _ = writeln!(out, " fillcolor=\"{NODE_FILL}\","); + let _ = writeln!(out, " color=\"{NODE_STROKE}\","); + let _ = writeln!(out, " fontcolor=\"{NODE_TEXT}\","); + let _ = writeln!(out, " penwidth=1,"); + let _ = writeln!(out, " margin=\"0.18,0.12\""); + let _ = writeln!(out, " ];\n"); + + let _ = writeln!(out, " edge ["); + let _ = writeln!(out, " color=\"{EDGE_COLOR}\","); + let _ = writeln!(out, " penwidth=1.2,"); + let _ = writeln!(out, " arrowsize=0.8"); + let _ = writeln!(out, " ];\n"); + + for (cluster_index, (app, indices)) in group_by_app(nodes).into_iter().enumerate() { + let _ = writeln!(out, " subgraph cluster_{cluster_index} {{"); + let _ = writeln!(out, " label=\"{}\";", escape_dot(app)); + let _ = writeln!(out, " style=\"rounded,filled\";"); + let _ = writeln!(out, " color=\"{CLUSTER_STROKE}\";"); + let _ = writeln!(out, " fillcolor=\"{CLUSTER_FILL}\";"); + let _ = writeln!(out, " fontcolor=\"{CLUSTER_TEXT}\";"); + let _ = writeln!(out, " fontsize=12;"); + let _ = writeln!(out, " margin=12;"); + for i in indices { + let dot_label = wrap_label(nodes[i].label) + .iter() + .map(|line| escape_dot(line)) + .collect::>() + .join("\\n"); + let _ = writeln!(out, " {} [label=\"{}\"];", nodes[i].id, dot_label); + } + let _ = writeln!(out, " }}"); + } + out.push('\n'); + + for (index, node) in nodes.iter().enumerate() { + for &dependent in graph.get_edges(index) { + let _ = writeln!(out, " {} -> {};", node.id, nodes[dependent].id); + } + } + + out.push_str("}\n"); + out +} + +fn escape_dot(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use cot::db::migrations::GraphFormat; + use cot::db::migrations::graph_export::GraphExporter; + + use crate::db::migrations::graph_export::dot::escape_dot; + use crate::db::migrations::graph_export::style::*; + use crate::db::migrations::{MigrationDependency, MigrationWrapper}; + use crate::test::TestMigration; + + fn wrap(migrations: Vec) -> Vec { + migrations.into_iter().map(MigrationWrapper::new).collect() + } + + fn render_dot(migrations: &[MigrationWrapper]) -> String { + let exporter = GraphExporter::new(migrations); + exporter.export(GraphFormat::Dot).unwrap() + } + + #[test] + fn dot_contains_edge_and_cluster() { + let migrations = wrap(vec![ + TestMigration::new("app1", "m1", [], []), + TestMigration::new( + "app1", + "m2", + [MigrationDependency::migration("app1", "m1")], + [], + ), + ]); + + let dot = render_dot(&migrations); + + assert!(dot.contains("digraph migrations")); + assert!(dot.contains("subgraph cluster_0")); + assert!(dot.contains("n0 -> n1;")); + assert!(dot.contains(NODE_FILL)); + } + + #[test] + fn escapes_quotes_in_labels() { + assert_eq!(escape_dot(r#"a"b"#), r#"a\"b"#); + } + + #[test] + fn escape_dot_empty_string() { + assert_eq!(escape_dot(""), ""); + } + + #[test] + fn escape_dot_backslash_only() { + assert_eq!(escape_dot(r"a\b"), r"a\\b"); + } + + #[test] + fn escape_dot_backslash_and_quote_combined() { + let escaped = escape_dot("a\\\"b"); + assert_eq!(escaped.matches('\\').count(), 3); + assert_eq!(escaped.matches('"').count(), 1); + assert!(escaped.starts_with('a')); + assert!(escaped.ends_with('b')); + } + + #[test] + fn dot_wraps_long_label_with_literal_newline() { + let migrations = wrap(vec![TestMigration::new( + "app1", + "m_0002_auto_20260527_004236", + [], + [], + )]); + assert!(render_dot(&migrations).contains("\\n")); + } + + #[test] + fn dot_single_migration_no_edges() { + let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); + let dot = render_dot(&migrations); + + assert!(dot.contains("subgraph cluster_0")); + assert!(dot.contains("n0 [label=\"m1\"];")); + assert!(!dot.contains("->")); + } + + #[test] + fn dot_clusters_sorted_alphabetically_by_app() { + let migrations = wrap(vec![ + TestMigration::new("zeta", "m1", [], []), + TestMigration::new("alpha", "m1", [], []), + ]); + let dot = render_dot(&migrations); + + let alpha_pos = dot.find("label=\"alpha\";").expect("alpha cluster present"); + let zeta_pos = dot.find("label=\"zeta\";").expect("zeta cluster present"); + assert!(alpha_pos < zeta_pos); + } + + #[test] + fn dot_multiple_migrations_same_app_share_one_cluster() { + let migrations = wrap(vec![ + TestMigration::new("app1", "m1", [], []), + TestMigration::new( + "app1", + "m2", + [MigrationDependency::migration("app1", "m1")], + [], + ), + ]); + assert_eq!( + render_dot(&migrations).matches("subgraph cluster_").count(), + 1 + ); + } + + #[test] + fn node_ids_assigned_in_input_order_not_sorted_order() { + let migrations = wrap(vec![ + TestMigration::new("zeta", "first", [], []), + TestMigration::new("alpha", "second", [], []), + ]); + let dot = render_dot(&migrations); + + assert!(dot.contains("n0 [label=\"first\"];")); + assert!(dot.contains("n1 [label=\"second\"];")); + } + + #[test] + fn dot_diamond_dependency_all_edges_rendered() { + let migrations = wrap(vec![ + TestMigration::new("diamond", "a", [], []), + TestMigration::new( + "diamond", + "b", + [MigrationDependency::migration("diamond", "a")], + [], + ), + TestMigration::new( + "diamond", + "c", + [MigrationDependency::migration("diamond", "a")], + [], + ), + TestMigration::new( + "diamond", + "d", + [ + MigrationDependency::migration("diamond", "b"), + MigrationDependency::migration("diamond", "c"), + ], + [], + ), + ]); + let dot = render_dot(&migrations); + + assert!(dot.contains("n0 -> n1;")); + assert!(dot.contains("n0 -> n2;")); + assert!(dot.contains("n1 -> n3;")); + assert!(dot.contains("n2 -> n3;")); + assert_eq!(dot.matches("->").count(), 4); + } + + #[test] + fn dot_cross_app_dependency_edge_render() { + let migrations = wrap(vec![ + TestMigration::new("upstream", "m1", [], []), + TestMigration::new( + "downstream", + "m1", + [MigrationDependency::migration("upstream", "m1")], + [], + ), + ]); + let dot = render_dot(&migrations); + + assert!(dot.contains("n0 -> n1;")); + assert_eq!(dot.matches("subgraph cluster_").count(), 2); + } + + #[test] + fn dot_render_does_not_fail_on_cyclic_dependencies() { + let migrations = wrap(vec![ + TestMigration::new( + "cyclic", + "a", + [MigrationDependency::migration("cyclic", "b")], + [], + ), + TestMigration::new( + "cyclic", + "b", + [MigrationDependency::migration("cyclic", "a")], + [], + ), + ]); + let dot = render_dot(&migrations); + + assert!(dot.contains("n0 -> n1;")); + assert!(dot.contains("n1 -> n0;")); + } + + #[test] + fn dot_escapes_quotes_in_app_name_cluster_label() { + let migrations = wrap(vec![TestMigration::new("weird\"app", "m1", [], [])]); + assert!(render_dot(&migrations).contains("weird\\\"app")); + } +} diff --git a/cot/src/db/migrations/graph_export/mermaid.rs b/cot/src/db/migrations/graph_export/mermaid.rs new file mode 100644 index 000000000..af3428b65 --- /dev/null +++ b/cot/src/db/migrations/graph_export/mermaid.rs @@ -0,0 +1,208 @@ +use std::fmt::Write; + +use super::{Node, group_by_app, wrap_label}; +use crate::utils::graph::Graph; + +pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { + #[allow(clippy::allow_attributes, clippy::wildcard_imports)] + use super::style::*; + + let mut out = String::new(); + + let _ = writeln!( + out, + "%%{{init: {{'theme': 'base', 'themeVariables': {{'background': 'transparent'}}}}}}%%" + ); + let _ = writeln!(out, "flowchart LR"); + let _ = writeln!( + out, + " classDef migration fill:{NODE_FILL},stroke:{NODE_STROKE},stroke-width:1px,color:{NODE_TEXT},font-size:12px,rx:6,ry:6;\n", + ); + + let mut all_node_ids = Vec::new(); + let clusters = group_by_app(nodes); + + for (cluster_index, (app, indices)) in clusters.iter().enumerate() { + let _ = writeln!( + out, + " subgraph cluster{cluster_index}[\"{}\"]", + escape_mermaid(app) + ); + for &i in indices { + let mermaid_label = wrap_label(nodes[i].label) + .iter() + .map(|line| escape_mermaid(line)) + .collect::>() + .join("
"); + let _ = writeln!(out, " {}[\"{}\"]", nodes[i].id, mermaid_label); + all_node_ids.push(nodes[i].id.clone()); + } + let _ = writeln!(out, " end"); + } + out.push('\n'); + + for (index, node) in nodes.iter().enumerate() { + for &dependent in graph.get_edges(index) { + let _ = writeln!(out, " {} --> {}", node.id, nodes[dependent].id); + } + } + out.push('\n'); + + if !all_node_ids.is_empty() { + let _ = writeln!(out, " class {} migration;", all_node_ids.join(",")); + } + for cluster_index in 0..clusters.len() { + let _ = writeln!( + out, + " style cluster{cluster_index} fill:{CLUSTER_FILL},stroke:{CLUSTER_STROKE},stroke-width:1px", + ); + } + let _ = writeln!( + out, + " linkStyle default stroke:{EDGE_COLOR},stroke-width:1.5px", + ); + + out +} + +fn escape_mermaid(s: &str) -> String { + s.replace('"', """) +} + +#[cfg(test)] +mod tests { + use cot::db::migrations::graph_export::GraphExporter; + + use super::*; + use crate::db::migrations::{GraphFormat, MigrationDependency, MigrationWrapper}; + use crate::test::TestMigration; + + fn wrap(migrations: Vec) -> Vec { + migrations.into_iter().map(MigrationWrapper::new).collect() + } + + fn render_mermaid(migrations: &[MigrationWrapper]) -> String { + let exporter = GraphExporter::new(migrations); + exporter.export(GraphFormat::Mermaid).unwrap() + } + + #[test] + fn mermaid_contains_edge_and_subgraph() { + let migrations = wrap(vec![ + TestMigration::new("app1", "m1", [], []), + TestMigration::new( + "app1", + "m2", + [MigrationDependency::migration("app1", "m1")], + [], + ), + ]); + + let mermaid = render_mermaid(&migrations); + + assert!(mermaid.contains("flowchart LR")); + assert!(mermaid.contains("subgraph cluster0")); + assert!(mermaid.contains("n0 --> n1")); + assert!(mermaid.contains("background': 'transparent'")); + assert!(mermaid.contains("classDef migration")); + } + + #[test] + fn escapes_quotes_in_labels() { + assert_eq!(escape_mermaid(r#"a"b"#), "a"b"); + } + + #[test] + fn escape_mermaid_empty_string() { + assert_eq!(escape_mermaid(""), ""); + } + + #[test] + fn escape_mermaid_multiple_quotes() { + assert_eq!(escape_mermaid("\"a\""), ""a""); + } + + #[test] + fn escape_mermaid_does_not_touch_backslashes() { + assert_eq!(escape_mermaid(r"a\b"), r"a\b"); + } + + #[test] + fn mermaid_wraps_long_label_with_br() { + let migrations = wrap(vec![TestMigration::new( + "app1", + "m_0002_auto_20260527_004236", + [], + [], + )]); + assert!(render_mermaid(&migrations).contains("
")); + } + + #[test] + fn mermaid_single_migration_no_edges() { + let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); + let mermaid = render_mermaid(&migrations); + + assert!(mermaid.contains("n0[\"m1\"]")); + assert!(mermaid.contains("class n0 migration;")); + assert!(!mermaid.contains("-->")); + } + + #[test] + fn mermaid_clusters_sorted_alphabetically_by_app() { + let migrations = wrap(vec![ + TestMigration::new("zeta", "m1", [], []), + TestMigration::new("alpha", "m1", [], []), + ]); + let mermaid = render_mermaid(&migrations); + + let alpha_pos = mermaid + .find("[\"alpha\"]") + .expect("alpha subgraph should be present"); + let zeta_pos = mermaid + .find("[\"zeta\"]") + .expect("zeta subgraph should be present"); + assert!(alpha_pos < zeta_pos); + } + + #[test] + fn mermaid_diamond_dependency_all_edges_rendered() { + let migrations = wrap(vec![ + TestMigration::new("diamond", "a", [], []), + TestMigration::new( + "diamond", + "b", + [MigrationDependency::migration("diamond", "a")], + [], + ), + TestMigration::new( + "diamond", + "c", + [MigrationDependency::migration("diamond", "a")], + [], + ), + TestMigration::new( + "diamond", + "d", + [ + MigrationDependency::migration("diamond", "b"), + MigrationDependency::migration("diamond", "c"), + ], + [], + ), + ]); + let mermaid = render_mermaid(&migrations); + + assert!(mermaid.contains("n0 --> n1")); + assert!(mermaid.contains("n0 --> n2")); + assert!(mermaid.contains("n1 --> n3")); + assert!(mermaid.contains("n2 --> n3")); + assert_eq!(mermaid.matches("-->").count(), 4); + } + + #[test] + fn mermaid_escapes_quotes_in_app_name_subgraph_label() { + let migrations = wrap(vec![TestMigration::new("weird\"app", "m1", [], [])]); + assert!(render_mermaid(&migrations).contains("weird"app")); + } +} diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_dependent_apps.snap new file mode 100644 index 000000000..ff6f2ce33 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_dependent_apps.snap @@ -0,0 +1,66 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: dot +--- +digraph migrations { + rankdir=LR; + splines=spline; + nodesep=0.4; + ranksep=0.6; + bgcolor="transparent"; + + graph [fontname="Helvetica,Arial,sans-serif"]; + node [fontname="Helvetica,Arial,sans-serif", fontsize=11]; + edge [fontname="Helvetica,Arial,sans-serif", fontsize=9]; + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2ff", + color="#4c51bf", + fontcolor="#1e1b4b", + penwidth=1, + margin="0.18,0.12" + ]; + + edge [ + color="#9aa5b1", + penwidth=1.2, + arrowsize=0.8 + ]; + + subgraph cluster_0 { + label="app1"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n0 [label="m_0001_initial"]; + n1 [label="m_0002_second"]; + } + subgraph cluster_1 { + label="app2"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n2 [label="m_0001_initial"]; + } + subgraph cluster_2 { + label="dependent"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n3 [label="m_0001_initial"]; + } + + n0 -> n1; + n1 -> n3; +} diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_single_app.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_single_app.snap new file mode 100644 index 000000000..f3678cd4d --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_single_app.snap @@ -0,0 +1,47 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: dot +--- +digraph migrations { + rankdir=LR; + splines=spline; + nodesep=0.4; + ranksep=0.6; + bgcolor="transparent"; + + graph [fontname="Helvetica,Arial,sans-serif"]; + node [fontname="Helvetica,Arial,sans-serif", fontsize=11]; + edge [fontname="Helvetica,Arial,sans-serif", fontsize=9]; + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2ff", + color="#4c51bf", + fontcolor="#1e1b4b", + penwidth=1, + margin="0.18,0.12" + ]; + + edge [ + color="#9aa5b1", + penwidth=1.2, + arrowsize=0.8 + ]; + + subgraph cluster_0 { + label="app1"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n0 [label="m_0001_initial"]; + n1 [label="m_0002_second"]; + n2 [label="m_0003_third"]; + } + + n0 -> n1; + n1 -> n2; +} diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_unrelated_apps.snap new file mode 100644 index 000000000..f248cdc84 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_unrelated_apps.snap @@ -0,0 +1,75 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: dot +--- +digraph migrations { + rankdir=LR; + splines=spline; + nodesep=0.4; + ranksep=0.6; + bgcolor="transparent"; + + graph [fontname="Helvetica,Arial,sans-serif"]; + node [fontname="Helvetica,Arial,sans-serif", fontsize=11]; + edge [fontname="Helvetica,Arial,sans-serif", fontsize=9]; + + node [ + shape=box, + style="rounded,filled", + fillcolor="#eef2ff", + color="#4c51bf", + fontcolor="#1e1b4b", + penwidth=1, + margin="0.18,0.12" + ]; + + edge [ + color="#9aa5b1", + penwidth=1.2, + arrowsize=0.8 + ]; + + subgraph cluster_0 { + label="app1"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n0 [label="m_0001_initial"]; + n1 [label="m_0002_second"]; + } + subgraph cluster_1 { + label="app2"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n2 [label="m_0001_initial"]; + } + subgraph cluster_2 { + label="cot"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n3 [label="m_0001_initial"]; + } + subgraph cluster_3 { + label="cot_session"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n4 [label="m_0001_initial"]; + } + + n0 -> n1; +} diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_dependent_apps.snap new file mode 100644 index 000000000..c0ca7234d --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_dependent_apps.snap @@ -0,0 +1,27 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: mermaid +--- +%%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% +flowchart LR + classDef migration fill:#eef2ff,stroke:#4c51bf,stroke-width:1px,color:#1e1b4b,font-size:12px,rx:6,ry:6; + + subgraph cluster0["app1"] + n0["m_0001_initial"] + n1["m_0002_second"] + end + subgraph cluster1["app2"] + n2["m_0001_initial"] + end + subgraph cluster2["dependent"] + n3["m_0001_initial"] + end + + n0 --> n1 + n1 --> n3 + + class n0,n1,n2,n3 migration; + style cluster0 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + style cluster1 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + style cluster2 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + linkStyle default stroke:#9aa5b1,stroke-width:1.5px diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_single_app.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_single_app.snap new file mode 100644 index 000000000..df8ecaa96 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_single_app.snap @@ -0,0 +1,20 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: mermaid +--- +%%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% +flowchart LR + classDef migration fill:#eef2ff,stroke:#4c51bf,stroke-width:1px,color:#1e1b4b,font-size:12px,rx:6,ry:6; + + subgraph cluster0["app1"] + n0["m_0001_initial"] + n1["m_0002_second"] + n2["m_0003_third"] + end + + n0 --> n1 + n1 --> n2 + + class n0,n1,n2 migration; + style cluster0 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + linkStyle default stroke:#9aa5b1,stroke-width:1.5px diff --git a/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_unrelated_apps.snap new file mode 100644 index 000000000..7ef737c00 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_unrelated_apps.snap @@ -0,0 +1,30 @@ +--- +source: cot/src/db/migrations/graph_export.rs +expression: mermaid +--- +%%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% +flowchart LR + classDef migration fill:#eef2ff,stroke:#4c51bf,stroke-width:1px,color:#1e1b4b,font-size:12px,rx:6,ry:6; + + subgraph cluster0["app1"] + n0["m_0001_initial"] + n1["m_0002_second"] + end + subgraph cluster1["app2"] + n2["m_0001_initial"] + end + subgraph cluster2["cot"] + n3["m_0001_initial"] + end + subgraph cluster3["cot_session"] + n4["m_0001_initial"] + end + + n0 --> n1 + + class n0,n1,n2,n3,n4 migration; + style cluster0 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + style cluster1 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + style cluster2 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + style cluster3 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px + linkStyle default stroke:#9aa5b1,stroke-width:1.5px