From 3caaa36b359df45b86e501bc9827d89778826ffc Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 27 Aug 2026 17:40:55 +0000 Subject: [PATCH 1/8] Add migration graph subcommand to generate a graph for visualization --- cot-core/src/error/error_impl.rs | 3 + cot/src/cli.rs | 71 +++++ cot/src/db/migrations.rs | 11 + cot/src/db/migrations/graph_export.rs | 363 ++++++++++++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 cot/src/db/migrations/graph_export.rs diff --git a/cot-core/src/error/error_impl.rs b/cot-core/src/error/error_impl.rs index 80a2f0f3a..6d80e9648 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; diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 1c7232ccc..781d0177a 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -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,74 @@ 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 rendered = engine.to_graph(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 { @@ -670,6 +740,7 @@ macro_rules! metadata { pub use metadata; +use crate::db::migrations::GraphFormat; use crate::project::{StartServerError, WithConfig}; use crate::static_files::StaticFiles; diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 126c28542..7e9685a7b 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 use graph_export::GraphFormat; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -486,6 +488,15 @@ impl MigrationEngine { .await?; Ok(()) } + /// Renders the migration dependency graph in the given [`GraphFormat`] + /// for visualization with external tools (e.g. Graphviz `dot`, mermaid). + /// + /// # Errors + /// + /// Returns an error if the dependency graph cannot be generated + pub fn to_graph(&self, format: GraphFormat) -> Result { + graph_export::render(&self.migrations, format) + } } /// 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..4794b4461 --- /dev/null +++ b/cot/src/db/migrations/graph_export.rs @@ -0,0 +1,363 @@ +use std::collections::HashMap; +use std::fmt::Write; + +use cot::db::migrations::MigrationEngineError; + +use crate::db::migrations::sorter::MigrationSorter; +use crate::db::migrations::{DynMigration, MigrationWrapper}; +use crate::utils::graph::Graph; + +/// The output format for a rendered migration dependency graph. +#[derive(Debug, Clone, Copy, PartialEq)] +pub 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; +} + +#[derive(Debug)] +struct Node<'a> { + id: String, + app: &'a str, + label: &'a str, +} + +pub(super) fn render( + migrations: &[MigrationWrapper], + format: GraphFormat, +) -> super::Result { + let graph = MigrationSorter::generate_graph(migrations).map_err(|e| { + MigrationEngineError::Custom(format!("Failed to generate migration graph: {e}")) + })?; + + let nodes = migrations + .iter() + .enumerate() + .map(|(i, m)| Node { + id: format!("n{i}"), + app: m.app_name(), + label: m.name(), + }) + .collect::>(); + + Ok(match format { + GraphFormat::Dot => render_dot(&nodes, &graph), + GraphFormat::Mermaid => render_mermaid(&nodes, &graph), + }) +} + +fn wrap_label(label: &str) -> Vec { + if label.len() <= style::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 > style::LABEL_WRAP_WIDTH && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + + if !current.is_empty() { + current.push('_'); + } + current.push_str(segment); + + // A single segment longer than the wrap width on its own: emit it as + // its own line rather than trying to split mid-word. + if current.len() > style::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 +} + +fn render_dot(nodes: &[Node<'_>], graph: &Graph) -> String { + 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=\"{}\"];", style::FONT_FAMILY); + let _ = writeln!( + out, + " node [fontname=\"{}\", fontsize=11];", + style::FONT_FAMILY + ); + let _ = writeln!( + out, + " edge [fontname=\"{}\", fontsize=9];\n", + style::FONT_FAMILY + ); + + let _ = writeln!(out, " node ["); + let _ = writeln!(out, " shape=box,"); + let _ = writeln!(out, " style=\"rounded,filled\","); + let _ = writeln!(out, " fillcolor=\"{}\",", style::NODE_FILL); + let _ = writeln!(out, " color=\"{}\",", style::NODE_STROKE); + let _ = writeln!(out, " fontcolor=\"{}\",", style::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=\"{}\",", style::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=\"{}\";", style::CLUSTER_STROKE); + let _ = writeln!(out, " fillcolor=\"{}\";", style::CLUSTER_FILL); + let _ = writeln!(out, " fontcolor=\"{}\";", style::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 render_mermaid(nodes: &[Node<'_>], graph: &Graph) -> String { + let mut out = String::new(); + + // Transparent background so the diagram doesn't carry a hardcoded white + // canvas regardless of where it's rendered. + let _ = writeln!( + out, + "%%{{init: {{'theme': 'base', 'themeVariables': {{'background': 'transparent'}}}}}}%%" + ); + let _ = writeln!(out, "flowchart LR"); + let _ = writeln!( + out, + " classDef migration fill:{},stroke:{},stroke-width:1px,color:{},font-size:12px,rx:6,ry:6;\n", + style::NODE_FILL, + style::NODE_STROKE, + style::NODE_TEXT + ); + + 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:{},stroke:{},stroke-width:1px", + style::CLUSTER_FILL, + style::CLUSTER_STROKE + ); + } + let _ = writeln!( + out, + " linkStyle default stroke:{},stroke-width:1.5px", + style::EDGE_COLOR + ); + + out +} + +fn escape_dot(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn escape_mermaid(s: &str) -> String { + s.replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::migrations::MigrationDependency; + use crate::test::TestMigration; + + fn wrap(migrations: Vec) -> Vec { + migrations.into_iter().map(MigrationWrapper::new).collect() + } + + #[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(&migrations, GraphFormat::Dot).unwrap(); + + assert!(dot.contains("digraph migrations")); + assert!(dot.contains("subgraph cluster_0")); + assert!(dot.contains("n0 -> n1;")); + assert!(dot.contains(style::NODE_FILL)); + } + + #[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(&migrations, GraphFormat::Mermaid).unwrap(); + + 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_dot(r#"a"b"#), r#"a\"b"#); + assert_eq!(escape_mermaid(r#"a"b"#), "a"b"); + } + + #[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() <= style::LABEL_WRAP_WIDTH + 8)); + assert_eq!(lines.join("_"), "m_0002_auto_20260527_004236"); + } + + #[test] + fn dot_wraps_long_label_with_literal_newline() { + let migrations = wrap(vec![TestMigration::new( + "app1", + "m_0002_auto_20260527_004236", + [], + [], + )]); + + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + assert!(dot.contains("\\n")); + } + + #[test] + fn mermaid_wraps_long_label_with_br() { + let migrations = wrap(vec![TestMigration::new( + "app1", + "m_0002_auto_20260527_004236", + [], + [], + )]); + + let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + assert!(mermaid.contains("
")); + } +} From c48c306b1fc2f6cbcba077e1ff8b322bcf155a4a Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 27 Aug 2026 22:42:47 +0000 Subject: [PATCH 2/8] tests and more tests --- cot-core/src/error/error_impl.rs | 22 +- cot/src/cli.rs | 3 +- cot/src/db/migrations/graph_export.rs | 277 ++++++++++++++++++ cot/tests/db_testing/migrations.rs | 70 ++++- ...s__migration_graph_dot_dependent_apps.snap | 66 +++++ ...tions__migration_graph_dot_single_app.snap | 47 +++ ...s__migration_graph_dot_unrelated_apps.snap | 75 +++++ ...igration_graph_mermaid_dependent_apps.snap | 27 ++ ...s__migration_graph_mermaid_single_app.snap | 20 ++ ...igration_graph_mermaid_unrelated_apps.snap | 30 ++ 10 files changed, 621 insertions(+), 16 deletions(-) create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap create mode 100644 cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap diff --git a/cot-core/src/error/error_impl.rs b/cot-core/src/error/error_impl.rs index 6d80e9648..2931143f0 100644 --- a/cot-core/src/error/error_impl.rs +++ b/cot-core/src/error/error_impl.rs @@ -304,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()); @@ -317,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}"); @@ -327,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()); @@ -378,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")); @@ -410,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 @@ -422,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 @@ -441,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 @@ -461,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 781d0177a..3e8399e40 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::{GraphFormat, MigrationEngine, SyncDynMigration}; use cot::project::BootstrappedProject; use derive_more::Debug; @@ -740,7 +740,6 @@ macro_rules! metadata { pub use metadata; -use crate::db::migrations::GraphFormat; use crate::project::{StartServerError, WithConfig}; use crate::static_files::StaticFiles; diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index 4794b4461..5dea147c3 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -322,6 +322,43 @@ mod tests { assert_eq!(escape_mermaid(r#"a"b"#), "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 input = "a\\\"b"; + let escaped = escape_dot(input); + + assert_eq!(escaped.matches('\\').count(), 3); + assert_eq!(escaped.matches('"').count(), 1); + assert!(escaped.starts_with('a')); + assert!(escaped.ends_with('b')); + } + + #[test] + fn escape_mermaid_empty_string() { + assert_eq!(escape_mermaid(""), ""); + } + + #[test] + fn escape_mermaid_multiple_quotes() { + let input = "\"a\""; // "a" + assert_eq!(escape_mermaid(input), ""a""); + } + + #[test] + fn escape_mermaid_does_not_touch_backslashes() { + assert_eq!(escape_mermaid(r"a\b"), r"a\b"); + } + #[test] fn wrap_label_short_label_unchanged() { assert_eq!(wrap_label("m_0001_initial"), vec!["m_0001_initial"]); @@ -360,4 +397,244 @@ mod tests { let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); assert!(mermaid.contains("
")); } + + #[test] + fn dot_render_empty_migrations() { + let migrations: Vec = Vec::new(); + let dot = render(&migrations, 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 mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + + assert!(mermaid.contains("flowchart LR")); + assert!(!mermaid.contains("subgraph")); + assert!(!mermaid.contains("-->")); + assert!(!mermaid.contains("n0")); + } + + #[test] + fn dot_single_migration_no_edges() { + let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + + assert!(dot.contains("subgraph cluster_0")); + assert!(dot.contains("n0 [label=\"m1\"];")); + assert!(!dot.contains("->")); + } + + #[test] + fn mermaid_single_migration_no_edges() { + let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); + let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + + assert!(mermaid.contains("n0[\"m1\"]")); + assert!(mermaid.contains("class n0 migration;")); + assert!(!mermaid.contains("-->")); + } + + #[test] + fn dot_clusters_sorted_alphabetically_by_app() { + let migrations = wrap(vec![ + TestMigration::new("zeta", "m1", [], []), + TestMigration::new("alpha", "m1", [], []), + ]); + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + + 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 mermaid_clusters_sorted_alphabetically_by_app() { + let migrations = wrap(vec![ + TestMigration::new("zeta", "m1", [], []), + TestMigration::new("alpha", "m1", [], []), + ]); + let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + + 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 dot_multiple_migrations_same_app_share_one_cluster() { + let migrations = wrap(vec![ + TestMigration::new("app1", "m1", [], []), + TestMigration::new( + "app1", + "m2", + [MigrationDependency::migration("app1", "m1")], + [], + ), + ]); + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + + assert_eq!(dot.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(&migrations, GraphFormat::Dot).unwrap(); + + 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(&migrations, GraphFormat::Dot).unwrap(); + + 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 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(&migrations, GraphFormat::Mermaid).unwrap(); + + 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 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(&migrations, GraphFormat::Dot).unwrap(); + + 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 result = render(&migrations, GraphFormat::Dot); + assert!(result.is_ok()); + let dot = result.unwrap(); + 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", [], [])]); + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + + assert!(dot.contains("weird\\\"app")); + } + + #[test] + fn mermaid_escapes_quotes_in_app_name_subgraph_label() { + let migrations = wrap(vec![TestMigration::new("weird\"app", "m1", [], [])]); + let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + + assert!(mermaid.contains("weird"app")); + } + + #[test] + fn render_dispatches_dot_vs_mermaid() { + let migrations = wrap(vec![TestMigration::new("app", "m1", [], [])]); + + let dot = render(&migrations, GraphFormat::Dot).unwrap(); + let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + + assert!(dot.contains("digraph migrations")); + assert!(!dot.contains("flowchart")); + assert!(mermaid.contains("flowchart LR")); + assert!(!mermaid.contains("digraph")); + } } diff --git a/cot/tests/db_testing/migrations.rs b/cot/tests/db_testing/migrations.rs index 07816024e..0900e26bc 100644 --- a/cot/tests/db_testing/migrations.rs +++ b/cot/tests/db_testing/migrations.rs @@ -1,8 +1,8 @@ use cot::App; use cot::auth::db::DatabaseUserApp; use cot::db::migrations::{ - Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, - wrap_migrations, + Field, GraphFormat, Migration, MigrationDependency, MigrationEngine, Operation, + SyncDynMigration, wrap_migrations, }; use cot::db::{Auto, Database, DatabaseField, Identifier}; use cot::session::db::SessionApp; @@ -437,3 +437,69 @@ async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { ) .await; } + +#[test] +fn test_migration_graph_single_app() { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + ]) + .unwrap(); + let dot = engine.to_graph(GraphFormat::Dot).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_single_app", dot); + }); + + let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_single_app", mermaid); + }); +} + +#[test] +fn test_migration_graph_unrelated_apps() { + let mut migrations = DatabaseUserApp::new().migrations(); + + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + + let engine = MigrationEngine::new(migrations).unwrap(); + + let dot = engine.to_graph(GraphFormat::Dot).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_unrelated_apps", dot); + }); + + let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_unrelated_apps", mermaid); + }); +} + +#[test] +fn test_migration_graph_dependent_apps() { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackDependentInitial as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + let dot = engine.to_graph(GraphFormat::Dot).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_dot_dependent_apps", dot); + }); + + let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!("migration_graph_mermaid_dependent_apps", mermaid); + }); +} diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap new file mode 100644 index 000000000..cd43b0a4e --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap @@ -0,0 +1,66 @@ +--- +source: cot/tests/db_testing/migrations.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="rollback_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="rollback_app2"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n2 [label="m_0001_initial"]; + } + subgraph cluster_2 { + label="rollback_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/db__db_testing__migrations__migration_graph_dot_single_app.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap new file mode 100644 index 000000000..29fcb326f --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap @@ -0,0 +1,47 @@ +--- +source: cot/tests/db_testing/migrations.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="rollback_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/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap new file mode 100644 index 000000000..dfdd94735 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap @@ -0,0 +1,75 @@ +--- +source: cot/tests/db_testing/migrations.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="cot"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n0 [label="m_0001_initial"]; + } + subgraph cluster_1 { + label="cot_session"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n1 [label="m_0001_initial"]; + } + subgraph cluster_2 { + label="rollback_app1"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n2 [label="m_0001_initial"]; + n3 [label="m_0002_second"]; + } + subgraph cluster_3 { + label="rollback_app2"; + style="rounded,filled"; + color="#d1d5db"; + fillcolor="#f9fafb"; + fontcolor="#374151"; + fontsize=12; + margin=12; + n4 [label="m_0001_initial"]; + } + + n2 -> n3; +} diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap new file mode 100644 index 000000000..189a5df96 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap @@ -0,0 +1,27 @@ +--- +source: cot/tests/db_testing/migrations.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["rollback_app1"] + n0["m_0001_initial"] + n1["m_0002_second"] + end + subgraph cluster1["rollback_app2"] + n2["m_0001_initial"] + end + subgraph cluster2["rollback_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/db__db_testing__migrations__migration_graph_mermaid_single_app.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap new file mode 100644 index 000000000..43a42ed19 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap @@ -0,0 +1,20 @@ +--- +source: cot/tests/db_testing/migrations.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["rollback_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/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap new file mode 100644 index 000000000..66cd3f56e --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap @@ -0,0 +1,30 @@ +--- +source: cot/tests/db_testing/migrations.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["cot"] + n0["m_0001_initial"] + end + subgraph cluster1["cot_session"] + n1["m_0001_initial"] + end + subgraph cluster2["rollback_app1"] + n2["m_0001_initial"] + n3["m_0002_second"] + end + subgraph cluster3["rollback_app2"] + n4["m_0001_initial"] + end + + n2 --> n3 + + 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 From 2322b11a52c6ffe160b4bab2cf7fc70195e86415 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 03:53:38 +0000 Subject: [PATCH 3/8] add non exhaustive --- cot/src/db/migrations/graph_export.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index 5dea147c3..bc2c0c621 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -9,6 +9,7 @@ use crate::utils::graph::Graph; /// The output format for a rendered migration dependency graph. #[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] pub enum GraphFormat { /// [Graphviz DOT](https://graphviz.org/doc/info/lang.html) format. Dot, From 2004afe21aecf82c3a658a68d45926f175b244d7 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 04:02:49 +0000 Subject: [PATCH 4/8] miri fix --- cot/tests/db_testing/migrations.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cot/tests/db_testing/migrations.rs b/cot/tests/db_testing/migrations.rs index 0900e26bc..1eca31a42 100644 --- a/cot/tests/db_testing/migrations.rs +++ b/cot/tests/db_testing/migrations.rs @@ -439,6 +439,10 @@ async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { } #[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([ @@ -459,6 +463,10 @@ fn test_migration_graph_single_app() { } #[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(); @@ -484,6 +492,10 @@ fn test_migration_graph_unrelated_apps() { } #[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([ From 57d348964f89a83286fc4943507dd91046a7b9f0 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 18:53:19 +0000 Subject: [PATCH 5/8] address PR comments --- cot/src/cli.rs | 5 +- cot/src/db/migrations.rs | 13 +- cot/src/db/migrations/graph_export.rs | 714 ++++++------------ cot/src/db/migrations/graph_export/dot.rs | 282 +++++++ cot/src/db/migrations/graph_export/mermaid.rs | 211 ++++++ cot/tests/db_testing/migrations.rs | 82 +- ...__migration_graph_dot_dependent_apps.snap} | 8 +- ...ests__migration_graph_dot_single_app.snap} | 4 +- ...__migration_graph_dot_unrelated_apps.snap} | 18 +- ...gration_graph_mermaid_dependent_apps.snap} | 8 +- ...__migration_graph_mermaid_single_app.snap} | 4 +- ...gration_graph_mermaid_unrelated_apps.snap} | 18 +- 12 files changed, 757 insertions(+), 610 deletions(-) create mode 100644 cot/src/db/migrations/graph_export/dot.rs create mode 100644 cot/src/db/migrations/graph_export/mermaid.rs rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_dot_dependent_apps.snap => cot__cli__tests__migration_graph_dot_dependent_apps.snap} (90%) rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_dot_single_app.snap => cot__cli__tests__migration_graph_dot_single_app.snap} (92%) rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap => cot__cli__tests__migration_graph_dot_unrelated_apps.snap} (88%) rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap => cot__cli__tests__migration_graph_mermaid_dependent_apps.snap} (80%) rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_mermaid_single_app.snap => cot__cli__tests__migration_graph_mermaid_single_app.snap} (85%) rename cot/tests/db_testing/snapshots/migrations/{db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap => cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap} (73%) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 3e8399e40..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::{GraphFormat, MigrationEngine, SyncDynMigration}; +use cot::db::migrations::{GraphExporter, GraphFormat, MigrationEngine, SyncDynMigration}; use cot::project::BootstrappedProject; use derive_more::Debug; @@ -714,7 +714,8 @@ impl CliTask for MigrationGraph { } let engine = MigrationEngine::new(migrations)?; - let rendered = engine.to_graph(format)?; + let exporter = GraphExporter::new(engine.migrations()); + let rendered = exporter.export(format)?; match matches.get_one::("output") { Some(path) => std::fs::write(path, rendered)?, diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 7e9685a7b..223c9befc 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -10,7 +10,7 @@ use std::io::Write; use std::{fmt, io}; pub use cot_macros::migration_op; -pub use graph_export::GraphFormat; +pub(crate) use graph_export::{GraphExporter, GraphFormat}; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -488,14 +488,9 @@ impl MigrationEngine { .await?; Ok(()) } - /// Renders the migration dependency graph in the given [`GraphFormat`] - /// for visualization with external tools (e.g. Graphviz `dot`, mermaid). - /// - /// # Errors - /// - /// Returns an error if the dependency graph cannot be generated - pub fn to_graph(&self, format: GraphFormat) -> Result { - graph_export::render(&self.migrations, format) + + pub(crate) fn migrations(&self) -> &[MigrationWrapper] { + &self.migrations } } diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index bc2c0c621..0b2ac5ed7 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -1,16 +1,22 @@ +//! Rendering of the migration dependency graph for external visualization +//! tools (Graphviz `dot`, Mermaid). Split into per-format submodules; this +//! file only holds what's shared between them: node/cluster layout and +//! label wrapping. + +mod dot; +mod mermaid; + use std::collections::HashMap; -use std::fmt::Write; use cot::db::migrations::MigrationEngineError; +use crate::db::migrations::DynMigration; use crate::db::migrations::sorter::MigrationSorter; -use crate::db::migrations::{DynMigration, MigrationWrapper}; -use crate::utils::graph::Graph; /// The output format for a rendered migration dependency graph. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] -pub enum GraphFormat { +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. @@ -43,35 +49,41 @@ mod style { pub(super) const LABEL_WRAP_WIDTH: usize = 16; } -#[derive(Debug)] struct Node<'a> { id: String, app: &'a str, label: &'a str, } -pub(super) fn render( - migrations: &[MigrationWrapper], - format: GraphFormat, -) -> super::Result { - let graph = MigrationSorter::generate_graph(migrations).map_err(|e| { - MigrationEngineError::Custom(format!("Failed to generate migration graph: {e}")) - })?; - - let nodes = migrations - .iter() - .enumerate() - .map(|(i, m)| Node { - id: format!("n{i}"), - app: m.app_name(), - label: m.name(), - }) - .collect::>(); +pub(crate) struct GraphExporter<'a, T> { + migrations: &'a [T], +} - Ok(match format { - GraphFormat::Dot => render_dot(&nodes, &graph), - GraphFormat::Mermaid => render_mermaid(&nodes, &graph), - }) +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 { @@ -98,8 +110,6 @@ fn wrap_label(label: &str) -> Vec { } current.push_str(segment); - // A single segment longer than the wrap width on its own: emit it as - // its own line rather than trying to split mid-word. if current.len() > style::LABEL_WRAP_WIDTH { lines.push(std::mem::take(&mut current)); } @@ -123,241 +133,118 @@ fn group_by_app<'a>(nodes: &[Node<'a>]) -> Vec<(&'a str, Vec)> { ord } -fn render_dot(nodes: &[Node<'_>], graph: &Graph) -> String { - 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=\"{}\"];", style::FONT_FAMILY); - let _ = writeln!( - out, - " node [fontname=\"{}\", fontsize=11];", - style::FONT_FAMILY - ); - let _ = writeln!( - out, - " edge [fontname=\"{}\", fontsize=9];\n", - style::FONT_FAMILY - ); - - let _ = writeln!(out, " node ["); - let _ = writeln!(out, " shape=box,"); - let _ = writeln!(out, " style=\"rounded,filled\","); - let _ = writeln!(out, " fillcolor=\"{}\",", style::NODE_FILL); - let _ = writeln!(out, " color=\"{}\",", style::NODE_STROKE); - let _ = writeln!(out, " fontcolor=\"{}\",", style::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=\"{}\",", style::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=\"{}\";", style::CLUSTER_STROKE); - let _ = writeln!(out, " fillcolor=\"{}\";", style::CLUSTER_FILL); - let _ = writeln!(out, " fontcolor=\"{}\";", style::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 render_mermaid(nodes: &[Node<'_>], graph: &Graph) -> String { - let mut out = String::new(); - - // Transparent background so the diagram doesn't carry a hardcoded white - // canvas regardless of where it's rendered. - let _ = writeln!( - out, - "%%{{init: {{'theme': 'base', 'themeVariables': {{'background': 'transparent'}}}}}}%%" - ); - let _ = writeln!(out, "flowchart LR"); - let _ = writeln!( - out, - " classDef migration fill:{},stroke:{},stroke-width:1px,color:{},font-size:12px,rx:6,ry:6;\n", - style::NODE_FILL, - style::NODE_STROKE, - style::NODE_TEXT - ); - - 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:{},stroke:{},stroke-width:1px", - style::CLUSTER_FILL, - style::CLUSTER_STROKE - ); - } - let _ = writeln!( - out, - " linkStyle default stroke:{},stroke-width:1.5px", - style::EDGE_COLOR - ); - - out -} - -fn escape_dot(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn escape_mermaid(s: &str) -> String { - s.replace('"', """) -} - #[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 super::*; - use crate::db::migrations::MigrationDependency; + use crate::App; + use crate::db::migrations::MigrationWrapper; use crate::test::TestMigration; - fn wrap(migrations: Vec) -> Vec { - migrations.into_iter().map(MigrationWrapper::new).collect() - } - - #[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(&migrations, GraphFormat::Dot).unwrap(); - - assert!(dot.contains("digraph migrations")); - assert!(dot.contains("subgraph cluster_0")); - assert!(dot.contains("n0 -> n1;")); - assert!(dot.contains(style::NODE_FILL)); + 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() } - #[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(&migrations, GraphFormat::Mermaid).unwrap(); - - 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_dot(r#"a"b"#), r#"a\"b"#); - assert_eq!(escape_mermaid(r#"a"b"#), "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 input = "a\\\"b"; - let escaped = escape_dot(input); - - assert_eq!(escaped.matches('\\').count(), 3); - assert_eq!(escaped.matches('"').count(), 1); - assert!(escaped.starts_with('a')); - assert!(escaped.ends_with('b')); - } - - #[test] - fn escape_mermaid_empty_string() { - assert_eq!(escape_mermaid(""), ""); - } - - #[test] - fn escape_mermaid_multiple_quotes() { - let input = "\"a\""; // "a" - assert_eq!(escape_mermaid(input), ""a""); - } - - #[test] - fn escape_mermaid_does_not_touch_backslashes() { - assert_eq!(escape_mermaid(r"a\b"), r"a\b"); + fn wrap(migrations: Vec) -> Vec { + migrations.into_iter().map(MigrationWrapper::new).collect() } #[test] @@ -373,36 +260,11 @@ mod tests { assert_eq!(lines.join("_"), "m_0002_auto_20260527_004236"); } - #[test] - fn dot_wraps_long_label_with_literal_newline() { - let migrations = wrap(vec![TestMigration::new( - "app1", - "m_0002_auto_20260527_004236", - [], - [], - )]); - - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - assert!(dot.contains("\\n")); - } - - #[test] - fn mermaid_wraps_long_label_with_br() { - let migrations = wrap(vec![TestMigration::new( - "app1", - "m_0002_auto_20260527_004236", - [], - [], - )]); - - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); - assert!(mermaid.contains("
")); - } - #[test] fn dot_render_empty_migrations() { let migrations: Vec = Vec::new(); - let dot = render(&migrations, GraphFormat::Dot).unwrap(); + let exporter = GraphExporter::new(&migrations); + let dot = exporter.export(GraphFormat::Dot).unwrap(); assert!(dot.starts_with("digraph migrations {")); assert!(dot.trim_end().ends_with('}')); @@ -413,7 +275,8 @@ mod tests { #[test] fn mermaid_render_empty_migrations() { let migrations: Vec = Vec::new(); - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); + let exporter = GraphExporter::new(&migrations); + let mermaid = exporter.export(GraphFormat::Mermaid).unwrap(); assert!(mermaid.contains("flowchart LR")); assert!(!mermaid.contains("subgraph")); @@ -422,220 +285,93 @@ mod tests { } #[test] - fn dot_single_migration_no_edges() { - let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - - assert!(dot.contains("subgraph cluster_0")); - assert!(dot.contains("n0 [label=\"m1\"];")); - assert!(!dot.contains("->")); - } - - #[test] - fn mermaid_single_migration_no_edges() { - let migrations = wrap(vec![TestMigration::new("solo", "m1", [], [])]); - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); - - assert!(mermaid.contains("n0[\"m1\"]")); - assert!(mermaid.contains("class n0 migration;")); - assert!(!mermaid.contains("-->")); - } - - #[test] - fn dot_clusters_sorted_alphabetically_by_app() { - let migrations = wrap(vec![ - TestMigration::new("zeta", "m1", [], []), - TestMigration::new("alpha", "m1", [], []), - ]); - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - - 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 mermaid_clusters_sorted_alphabetically_by_app() { - let migrations = wrap(vec![ - TestMigration::new("zeta", "m1", [], []), - TestMigration::new("alpha", "m1", [], []), - ]); - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); - - 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 dot_multiple_migrations_same_app_share_one_cluster() { - let migrations = wrap(vec![ - TestMigration::new("app1", "m1", [], []), - TestMigration::new( - "app1", - "m2", - [MigrationDependency::migration("app1", "m1")], - [], - ), - ]); - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - - assert_eq!(dot.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(&migrations, GraphFormat::Dot).unwrap(); - - 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(&migrations, GraphFormat::Dot).unwrap(); - - 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 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(&migrations, GraphFormat::Mermaid).unwrap(); - - 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 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(&migrations, GraphFormat::Dot).unwrap(); - - assert!(dot.contains("n0 -> n1;")); - assert_eq!(dot.matches("subgraph cluster_").count(), 2); - } + 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(); - #[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 result = render(&migrations, GraphFormat::Dot); - assert!(result.is_ok()); - let dot = result.unwrap(); - assert!(dot.contains("n0 -> n1;")); - assert!(dot.contains("n1 -> n0;")); + assert!(dot.contains("digraph migrations")); + assert!(!dot.contains("flowchart")); + assert!(mermaid.contains("flowchart LR")); + assert!(!mermaid.contains("digraph")); } #[test] - fn dot_escapes_quotes_in_app_name_cluster_label() { - let migrations = wrap(vec![TestMigration::new("weird\"app", "m1", [], [])]); - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - - assert!(dot.contains("weird\\\"app")); + #[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] - fn mermaid_escapes_quotes_in_app_name_subgraph_label() { - let migrations = wrap(vec![TestMigration::new("weird\"app", "m1", [], [])]); - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); - - assert!(mermaid.contains("weird"app")); + #[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] - fn render_dispatches_dot_vs_mermaid() { - let migrations = wrap(vec![TestMigration::new("app", "m1", [], [])]); - - let dot = render(&migrations, GraphFormat::Dot).unwrap(); - let mermaid = render(&migrations, GraphFormat::Mermaid).unwrap(); - - assert!(dot.contains("digraph migrations")); - assert!(!dot.contains("flowchart")); - assert!(mermaid.contains("flowchart LR")); - assert!(!mermaid.contains("digraph")); + #[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..a75d3a2b3 --- /dev/null +++ b/cot/src/db/migrations/graph_export/dot.rs @@ -0,0 +1,282 @@ +use std::fmt::Write; + +use super::{Node, group_by_app, style, wrap_label}; +use crate::utils::graph::Graph; + +pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { + 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=\"{}\"];", style::FONT_FAMILY); + let _ = writeln!( + out, + " node [fontname=\"{}\", fontsize=11];", + style::FONT_FAMILY + ); + let _ = writeln!( + out, + " edge [fontname=\"{}\", fontsize=9];\n", + style::FONT_FAMILY + ); + + let _ = writeln!(out, " node ["); + let _ = writeln!(out, " shape=box,"); + let _ = writeln!(out, " style=\"rounded,filled\","); + let _ = writeln!(out, " fillcolor=\"{}\",", style::NODE_FILL); + let _ = writeln!(out, " color=\"{}\",", style::NODE_STROKE); + let _ = writeln!(out, " fontcolor=\"{}\",", style::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=\"{}\",", style::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=\"{}\";", style::CLUSTER_STROKE); + let _ = writeln!(out, " fillcolor=\"{}\";", style::CLUSTER_FILL); + let _ = writeln!(out, " fontcolor=\"{}\";", style::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 super::*; + 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(style::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..3f8462389 --- /dev/null +++ b/cot/src/db/migrations/graph_export/mermaid.rs @@ -0,0 +1,211 @@ +use std::fmt::Write; + +use super::{Node, group_by_app, style, wrap_label}; +use crate::utils::graph::Graph; + +pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { + 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:{},stroke:{},stroke-width:1px,color:{},font-size:12px,rx:6,ry:6;\n", + style::NODE_FILL, + style::NODE_STROKE, + style::NODE_TEXT + ); + + 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:{},stroke:{},stroke-width:1px", + style::CLUSTER_FILL, + style::CLUSTER_STROKE + ); + } + let _ = writeln!( + out, + " linkStyle default stroke:{},stroke-width:1.5px", + style::EDGE_COLOR + ); + + 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/migrations.rs b/cot/tests/db_testing/migrations.rs index 1eca31a42..07816024e 100644 --- a/cot/tests/db_testing/migrations.rs +++ b/cot/tests/db_testing/migrations.rs @@ -1,8 +1,8 @@ use cot::App; use cot::auth::db::DatabaseUserApp; use cot::db::migrations::{ - Field, GraphFormat, Migration, MigrationDependency, MigrationEngine, Operation, - SyncDynMigration, wrap_migrations, + Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, + wrap_migrations, }; use cot::db::{Auto, Database, DatabaseField, Identifier}; use cot::session::db::SessionApp; @@ -437,81 +437,3 @@ async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { ) .await; } - -#[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([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - ]) - .unwrap(); - let dot = engine.to_graph(GraphFormat::Dot).unwrap(); - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!("migration_graph_dot_single_app", dot); - }); - - let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); - 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(&[ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ])); - migrations.extend(SessionApp::new().migrations()); - - let engine = MigrationEngine::new(migrations).unwrap(); - - let dot = engine.to_graph(GraphFormat::Dot).unwrap(); - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!("migration_graph_dot_unrelated_apps", dot); - }); - - let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); - 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([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackDependentInitial as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - let dot = engine.to_graph(GraphFormat::Dot).unwrap(); - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!("migration_graph_dot_dependent_apps", dot); - }); - - let mermaid = engine.to_graph(GraphFormat::Mermaid).unwrap(); - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!("migration_graph_mermaid_dependent_apps", mermaid); - }); -} diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_dependent_apps.snap similarity index 90% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_dependent_apps.snap index cd43b0a4e..da4c98b92 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_dependent_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_dependent_apps.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.rs expression: dot --- digraph migrations { @@ -30,7 +30,7 @@ digraph migrations { ]; subgraph cluster_0 { - label="rollback_app1"; + label="app1"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; @@ -41,7 +41,7 @@ digraph migrations { n1 [label="m_0002_second"]; } subgraph cluster_1 { - label="rollback_app2"; + label="app2"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; @@ -51,7 +51,7 @@ digraph migrations { n2 [label="m_0001_initial"]; } subgraph cluster_2 { - label="rollback_dependent"; + label="dependent"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_single_app.snap similarity index 92% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_single_app.snap index 29fcb326f..517bb007c 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_single_app.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_single_app.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.rs expression: dot --- digraph migrations { @@ -30,7 +30,7 @@ digraph migrations { ]; subgraph cluster_0 { - label="rollback_app1"; + label="app1"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_unrelated_apps.snap similarity index 88% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_unrelated_apps.snap index dfdd94735..0c5a2ea4f 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_dot_unrelated_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_unrelated_apps.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.rs expression: dot --- digraph migrations { @@ -30,7 +30,7 @@ digraph migrations { ]; subgraph cluster_0 { - label="cot"; + label="app1"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; @@ -38,30 +38,30 @@ digraph migrations { fontsize=12; margin=12; n0 [label="m_0001_initial"]; + n1 [label="m_0002_second"]; } subgraph cluster_1 { - label="cot_session"; + label="app2"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; fontcolor="#374151"; fontsize=12; margin=12; - n1 [label="m_0001_initial"]; + n2 [label="m_0001_initial"]; } subgraph cluster_2 { - label="rollback_app1"; + label="cot"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; fontcolor="#374151"; fontsize=12; margin=12; - n2 [label="m_0001_initial"]; - n3 [label="m_0002_second"]; + n3 [label="m_0001_initial"]; } subgraph cluster_3 { - label="rollback_app2"; + label="cot_session"; style="rounded,filled"; color="#d1d5db"; fillcolor="#f9fafb"; @@ -71,5 +71,5 @@ digraph migrations { n4 [label="m_0001_initial"]; } - n2 -> n3; + n0 -> n1; } diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_dependent_apps.snap similarity index 80% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_dependent_apps.snap index 189a5df96..9550442e4 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_dependent_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_dependent_apps.snap @@ -1,19 +1,19 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.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["rollback_app1"] + subgraph cluster0["app1"] n0["m_0001_initial"] n1["m_0002_second"] end - subgraph cluster1["rollback_app2"] + subgraph cluster1["app2"] n2["m_0001_initial"] end - subgraph cluster2["rollback_dependent"] + subgraph cluster2["dependent"] n3["m_0001_initial"] end diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_single_app.snap similarity index 85% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_single_app.snap index 43a42ed19..7623ca3ef 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_single_app.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_single_app.snap @@ -1,12 +1,12 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.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["rollback_app1"] + subgraph cluster0["app1"] n0["m_0001_initial"] n1["m_0002_second"] n2["m_0003_third"] diff --git a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap similarity index 73% rename from cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap index 66cd3f56e..024c3604f 100644 --- a/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_graph_mermaid_unrelated_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap @@ -1,26 +1,26 @@ --- -source: cot/tests/db_testing/migrations.rs +source: cot/src/cli.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["cot"] + subgraph cluster0["app1"] n0["m_0001_initial"] + n1["m_0002_second"] end - subgraph cluster1["cot_session"] - n1["m_0001_initial"] - end - subgraph cluster2["rollback_app1"] + subgraph cluster1["app2"] n2["m_0001_initial"] - n3["m_0002_second"] end - subgraph cluster3["rollback_app2"] + subgraph cluster2["cot"] + n3["m_0001_initial"] + end + subgraph cluster3["cot_session"] n4["m_0001_initial"] end - n2 --> n3 + n0 --> n1 class n0,n1,n2,n3,n4 migration; style cluster0 fill:#f9fafb,stroke:#d1d5db,stroke-width:1px From dec50de3038c4559f4b8e4f905f103991dd04e5b Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 18:57:30 +0000 Subject: [PATCH 6/8] docs improve --- cot/src/db/migrations/graph_export.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index 0b2ac5ed7..4e6d32c5b 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -1,8 +1,5 @@ //! Rendering of the migration dependency graph for external visualization -//! tools (Graphviz `dot`, Mermaid). Split into per-format submodules; this -//! file only holds what's shared between them: node/cluster layout and -//! label wrapping. - +//! tools (Graphviz `dot`, Mermaid). mod dot; mod mermaid; From de0639bffdb4707b264e4e5bddf08deffd0a3ce2 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 21:10:38 +0000 Subject: [PATCH 7/8] fix UI tests --- ...raph_export__tests__migration_graph_dot_dependent_apps.snap} | 2 +- ...s__graph_export__tests__migration_graph_dot_single_app.snap} | 2 +- ...raph_export__tests__migration_graph_dot_unrelated_apps.snap} | 2 +- ..._export__tests__migration_graph_mermaid_dependent_apps.snap} | 2 +- ...raph_export__tests__migration_graph_mermaid_single_app.snap} | 2 +- ..._export__tests__migration_graph_mermaid_unrelated_apps.snap} | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_dot_dependent_apps.snap => cot__db__migrations__graph_export__tests__migration_graph_dot_dependent_apps.snap} (96%) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_dot_single_app.snap => cot__db__migrations__graph_export__tests__migration_graph_dot_single_app.snap} (94%) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_dot_unrelated_apps.snap => cot__db__migrations__graph_export__tests__migration_graph_dot_unrelated_apps.snap} (96%) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_mermaid_dependent_apps.snap => cot__db__migrations__graph_export__tests__migration_graph_mermaid_dependent_apps.snap} (94%) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_mermaid_single_app.snap => cot__db__migrations__graph_export__tests__migration_graph_mermaid_single_app.snap} (91%) rename cot/tests/db_testing/snapshots/migrations/{cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap => cot__db__migrations__graph_export__tests__migration_graph_mermaid_unrelated_apps.snap} (94%) diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 96% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_dependent_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_dependent_apps.snap index da4c98b92..ff6f2ce33 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: dot --- digraph migrations { diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 94% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_single_app.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_single_app.snap index 517bb007c..f3678cd4d 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: dot --- digraph migrations { diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 96% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_dot_unrelated_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_dot_unrelated_apps.snap index 0c5a2ea4f..f248cdc84 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: dot --- digraph migrations { diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 94% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_dependent_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_dependent_apps.snap index 9550442e4..c0ca7234d 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: mermaid --- %%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 91% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_single_app.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_single_app.snap index 7623ca3ef..df8ecaa96 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: mermaid --- %%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% diff --git a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 similarity index 94% rename from cot/tests/db_testing/snapshots/migrations/cot__cli__tests__migration_graph_mermaid_unrelated_apps.snap rename to cot/tests/db_testing/snapshots/migrations/cot__db__migrations__graph_export__tests__migration_graph_mermaid_unrelated_apps.snap index 024c3604f..7ef737c00 100644 --- a/cot/tests/db_testing/snapshots/migrations/cot__cli__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 @@ -1,5 +1,5 @@ --- -source: cot/src/cli.rs +source: cot/src/db/migrations/graph_export.rs expression: mermaid --- %%{init: {'theme': 'base', 'themeVariables': {'background': 'transparent'}}}%% From 5d252f45017cd358ba9cffe0abd600d6f6a766d9 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 28 Aug 2026 21:33:47 +0000 Subject: [PATCH 8/8] style import kitchen sink --- cot/src/db/migrations/graph_export.rs | 12 ++++-- cot/src/db/migrations/graph_export/dot.rs | 38 +++++++++---------- cot/src/db/migrations/graph_export/mermaid.rs | 17 ++++----- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index 4e6d32c5b..8c18dd3e0 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -84,7 +84,10 @@ impl<'a, T: DynMigration> GraphExporter<'a, T> { } fn wrap_label(label: &str) -> Vec { - if label.len() <= style::LABEL_WRAP_WIDTH { + #[allow(clippy::allow_attributes, clippy::wildcard_imports)] + use style::*; + + if label.len() <= LABEL_WRAP_WIDTH { return vec![label.to_owned()]; } @@ -98,7 +101,7 @@ fn wrap_label(label: &str) -> Vec { current.len() + 1 + segment.len() }; - if candidate_len > style::LABEL_WRAP_WIDTH && !current.is_empty() { + if candidate_len > LABEL_WRAP_WIDTH && !current.is_empty() { lines.push(std::mem::take(&mut current)); } @@ -107,7 +110,7 @@ fn wrap_label(label: &str) -> Vec { } current.push_str(segment); - if current.len() > style::LABEL_WRAP_WIDTH { + if current.len() > LABEL_WRAP_WIDTH { lines.push(std::mem::take(&mut current)); } } @@ -139,6 +142,7 @@ mod tests { }; use cot::db::{DatabaseField, Identifier}; use cot::session::db::SessionApp; + use style::*; use super::*; use crate::App; @@ -253,7 +257,7 @@ mod tests { 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() <= style::LABEL_WRAP_WIDTH + 8)); + assert!(lines.iter().all(|l| l.len() <= LABEL_WRAP_WIDTH + 8)); assert_eq!(lines.join("_"), "m_0002_auto_20260527_004236"); } diff --git a/cot/src/db/migrations/graph_export/dot.rs b/cot/src/db/migrations/graph_export/dot.rs index a75d3a2b3..3dc87763c 100644 --- a/cot/src/db/migrations/graph_export/dot.rs +++ b/cot/src/db/migrations/graph_export/dot.rs @@ -1,9 +1,12 @@ use std::fmt::Write; -use super::{Node, group_by_app, style, wrap_label}; +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;"); @@ -12,30 +15,22 @@ pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { let _ = writeln!(out, " ranksep=0.6;"); let _ = writeln!(out, " bgcolor=\"transparent\";\n"); - let _ = writeln!(out, " graph [fontname=\"{}\"];", style::FONT_FAMILY); - let _ = writeln!( - out, - " node [fontname=\"{}\", fontsize=11];", - style::FONT_FAMILY - ); - let _ = writeln!( - out, - " edge [fontname=\"{}\", fontsize=9];\n", - style::FONT_FAMILY - ); + 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=\"{}\",", style::NODE_FILL); - let _ = writeln!(out, " color=\"{}\",", style::NODE_STROKE); - let _ = writeln!(out, " fontcolor=\"{}\",", style::NODE_TEXT); + 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=\"{}\",", style::EDGE_COLOR); + let _ = writeln!(out, " color=\"{EDGE_COLOR}\","); let _ = writeln!(out, " penwidth=1.2,"); let _ = writeln!(out, " arrowsize=0.8"); let _ = writeln!(out, " ];\n"); @@ -44,9 +39,9 @@ pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { let _ = writeln!(out, " subgraph cluster_{cluster_index} {{"); let _ = writeln!(out, " label=\"{}\";", escape_dot(app)); let _ = writeln!(out, " style=\"rounded,filled\";"); - let _ = writeln!(out, " color=\"{}\";", style::CLUSTER_STROKE); - let _ = writeln!(out, " fillcolor=\"{}\";", style::CLUSTER_FILL); - let _ = writeln!(out, " fontcolor=\"{}\";", style::CLUSTER_TEXT); + 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 { @@ -80,7 +75,8 @@ mod tests { use cot::db::migrations::GraphFormat; use cot::db::migrations::graph_export::GraphExporter; - use super::*; + 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; @@ -110,7 +106,7 @@ mod tests { assert!(dot.contains("digraph migrations")); assert!(dot.contains("subgraph cluster_0")); assert!(dot.contains("n0 -> n1;")); - assert!(dot.contains(style::NODE_FILL)); + assert!(dot.contains(NODE_FILL)); } #[test] diff --git a/cot/src/db/migrations/graph_export/mermaid.rs b/cot/src/db/migrations/graph_export/mermaid.rs index 3f8462389..af3428b65 100644 --- a/cot/src/db/migrations/graph_export/mermaid.rs +++ b/cot/src/db/migrations/graph_export/mermaid.rs @@ -1,9 +1,12 @@ use std::fmt::Write; -use super::{Node, group_by_app, style, wrap_label}; +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!( @@ -13,10 +16,7 @@ pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { let _ = writeln!(out, "flowchart LR"); let _ = writeln!( out, - " classDef migration fill:{},stroke:{},stroke-width:1px,color:{},font-size:12px,rx:6,ry:6;\n", - style::NODE_FILL, - style::NODE_STROKE, - style::NODE_TEXT + " 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(); @@ -54,15 +54,12 @@ pub(super) fn render(nodes: &[Node<'_>], graph: &Graph) -> String { for cluster_index in 0..clusters.len() { let _ = writeln!( out, - " style cluster{cluster_index} fill:{},stroke:{},stroke-width:1px", - style::CLUSTER_FILL, - style::CLUSTER_STROKE + " style cluster{cluster_index} fill:{CLUSTER_FILL},stroke:{CLUSTER_STROKE},stroke-width:1px", ); } let _ = writeln!( out, - " linkStyle default stroke:{},stroke-width:1.5px", - style::EDGE_COLOR + " linkStyle default stroke:{EDGE_COLOR},stroke-width:1.5px", ); out