Skip to main content

dlin_core/render/
mod.rs

1use std::io;
2
3pub mod ascii;
4#[cfg(feature = "column-lineage")]
5pub mod column_graph;
6pub mod dot;
7pub mod html;
8pub mod impact;
9pub mod json;
10pub mod layout;
11pub mod list;
12pub mod mermaid;
13pub mod plain;
14pub mod summary;
15pub mod svg;
16
17/// Handle an I/O result from writing to stdout.
18/// Silently ignores `BrokenPipe` errors (e.g. `cmd | head`).
19pub(crate) fn handle_stdout_result(result: io::Result<()>) {
20    if let Err(e) = result
21        && e.kind() != io::ErrorKind::BrokenPipe
22    {
23        eprintln!("error writing output: {}", e);
24    }
25}
26
27/// Capitalize the first letter of a string (e.g. "model" -> "Model")
28pub(crate) fn capitalize(s: &str) -> String {
29    let mut chars = s.chars();
30    match chars.next() {
31        None => String::new(),
32        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
33    }
34}
35
36/// Label used for nodes that have no file_path when grouping by directory.
37pub(crate) const NO_DIRECTORY_LABEL: &str = "(other)";
38
39/// Extract the directory portion from a node's file_path for directory grouping.
40/// Returns the parent directory as a string (e.g. "models/staging"), or
41/// `NO_DIRECTORY_LABEL` if the node has no file_path.
42pub(crate) fn directory_label(node: &crate::graph::types::NodeData) -> String {
43    match &node.file_path {
44        Some(path) => path
45            .parent()
46            .filter(|p| !p.as_os_str().is_empty())
47            .map(|p| {
48                // Normalize separators to '/' for consistent output across platforms
49                p.components()
50                    .map(|c| c.as_os_str().to_string_lossy().into_owned())
51                    .collect::<Vec<String>>()
52                    .join("/")
53            })
54            .unwrap_or_else(|| NO_DIRECTORY_LABEL.to_string()),
55        None => NO_DIRECTORY_LABEL.to_string(),
56    }
57}
58
59/// Escape characters that are special inside Mermaid double-quoted labels.
60///
61/// Mermaid uses `#entity;` syntax (not HTML `&entity;`).
62/// We escape `"`, `<`, `>`, and `#` so user-provided text cannot break
63/// the label syntax or interfere with `<br/>` separators we insert.
64pub(crate) fn mermaid_escape(s: &str) -> String {
65    let mut out = String::with_capacity(s.len());
66    for ch in s.chars() {
67        match ch {
68            '#' => out.push_str("#num;"),
69            '"' => out.push_str("#quot;"),
70            '<' => out.push_str("#lt;"),
71            '>' => out.push_str("#gt;"),
72            _ => out.push(ch),
73        }
74    }
75    out
76}
77
78/// Sanitize a string into a valid identifier for DOT/Mermaid (only `[A-Za-z0-9_]`).
79pub(crate) fn sanitize_id(s: &str) -> String {
80    s.chars()
81        .map(|c| {
82            if c.is_ascii_alphanumeric() || c == '_' {
83                c
84            } else {
85                '_'
86            }
87        })
88        .collect()
89}
90
91/// Convert a `serde_json::Error` into an `io::Error`, preserving the
92/// underlying I/O error kind (e.g. `BrokenPipe`) when present.
93pub(crate) fn serde_io_error(e: serde_json::Error) -> io::Error {
94    match e.io_error_kind() {
95        Some(kind) => io::Error::new(kind, e),
96        None => io::Error::other(e),
97    }
98}
99
100#[cfg(test)]
101pub(crate) mod test_helpers {
102    use crate::graph::types::*;
103
104    pub fn make_node(unique_id: &str, label: &str, node_type: NodeType) -> NodeData {
105        NodeData {
106            unique_id: unique_id.into(),
107            label: label.into(),
108            node_type,
109            file_path: None,
110            description: None,
111            materialization: None,
112            tags: vec![],
113            columns: vec![],
114            exposure: None,
115        }
116    }
117
118    pub fn make_node_with_columns(
119        unique_id: &str,
120        label: &str,
121        node_type: NodeType,
122        columns: &[&str],
123    ) -> NodeData {
124        NodeData {
125            unique_id: unique_id.into(),
126            label: label.into(),
127            node_type,
128            file_path: None,
129            description: None,
130            materialization: None,
131            tags: vec![],
132            columns: columns.iter().map(|s| s.to_string()).collect(),
133            exposure: None,
134        }
135    }
136
137    pub fn make_node_with_path(
138        unique_id: &str,
139        label: &str,
140        node_type: NodeType,
141        path: &str,
142    ) -> NodeData {
143        NodeData {
144            unique_id: unique_id.into(),
145            label: label.into(),
146            node_type,
147            file_path: Some(path.into()),
148            description: None,
149            materialization: None,
150            tags: vec![],
151            columns: vec![],
152            exposure: None,
153        }
154    }
155
156    /// Build a representative lineage graph for snapshot tests:
157    /// source -> staging -> mart -> test, mart -> exposure
158    pub fn make_sample_lineage_graph() -> LineageGraph {
159        let mut graph = LineageGraph::new();
160        let src = graph.add_node(make_node(
161            "source.raw.orders",
162            "raw.orders",
163            NodeType::Source,
164        ));
165        let stg = graph.add_node(make_node("model.stg_orders", "stg_orders", NodeType::Model));
166        let mart = graph.add_node(make_node("model.orders", "orders", NodeType::Model));
167        let t = graph.add_node(make_node(
168            "test.orders_positive",
169            "orders_positive",
170            NodeType::Test,
171        ));
172        let exp = graph.add_node(make_node(
173            "exposure.dashboard",
174            "dashboard",
175            NodeType::Exposure,
176        ));
177
178        graph.add_edge(src, stg, EdgeData::direct(EdgeType::Source));
179        graph.add_edge(stg, mart, EdgeData::direct(EdgeType::Ref));
180        graph.add_edge(mart, t, EdgeData::direct(EdgeType::Test));
181        graph.add_edge(mart, exp, EdgeData::direct(EdgeType::Exposure));
182
183        graph
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_sanitize_id_directory_with_hyphens() {
193        assert_eq!(sanitize_id("models/my-project"), "models_my_project");
194    }
195
196    #[test]
197    fn test_sanitize_id_parentheses() {
198        assert_eq!(sanitize_id("(other)"), "_other_");
199    }
200
201    #[test]
202    fn test_sanitize_id_empty() {
203        assert_eq!(sanitize_id(""), "");
204    }
205
206    #[test]
207    fn test_sanitize_id_already_valid() {
208        assert_eq!(sanitize_id("models_staging"), "models_staging");
209    }
210
211    #[test]
212    fn test_sanitize_id_non_ascii() {
213        assert_eq!(sanitize_id("モデル/日本語"), "_______");
214    }
215
216    #[test]
217    fn test_capitalize() {
218        assert_eq!(capitalize("model"), "Model");
219        assert_eq!(capitalize(""), "");
220    }
221
222    #[test]
223    fn test_directory_label_with_path() {
224        let node = test_helpers::make_node_with_path(
225            "model.a",
226            "a",
227            crate::graph::types::NodeType::Model,
228            "models/staging/a.sql",
229        );
230        assert_eq!(directory_label(&node), "models/staging");
231    }
232
233    #[test]
234    fn test_directory_label_without_path() {
235        let node =
236            test_helpers::make_node("exposure.e", "e", crate::graph::types::NodeType::Exposure);
237        assert_eq!(directory_label(&node), NO_DIRECTORY_LABEL);
238    }
239
240    #[test]
241    fn test_directory_label_file_at_root() {
242        let node = test_helpers::make_node_with_path(
243            "model.a",
244            "a",
245            crate::graph::types::NodeType::Model,
246            "a.sql",
247        );
248        assert_eq!(directory_label(&node), NO_DIRECTORY_LABEL);
249    }
250}