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/// Escape characters that are special inside DOT double-quoted strings.
79///
80/// DOT requires `\` and `"` to be backslash-escaped inside double-quoted
81/// strings. Without this, user-provided text (e.g. YAML `label:` values)
82/// containing these characters would produce syntactically invalid DOT output.
83pub(crate) fn dot_escape(s: &str) -> String {
84    let mut out = String::with_capacity(s.len());
85    for ch in s.chars() {
86        match ch {
87            '\\' => out.push_str(r"\\"),
88            '"' => out.push_str("\\\""),
89            '\n' => out.push_str(r"\n"),
90            '\r' => out.push_str(r"\r"),
91            '\t' => out.push_str(r"\t"),
92            _ => out.push(ch),
93        }
94    }
95    out
96}
97
98/// Sanitize a string into a valid identifier for DOT/Mermaid (only `[A-Za-z0-9_]`).
99pub(crate) fn sanitize_id(s: &str) -> String {
100    s.chars()
101        .map(|c| {
102            if c.is_ascii_alphanumeric() || c == '_' {
103                c
104            } else {
105                '_'
106            }
107        })
108        .collect()
109}
110
111/// Convert a `serde_json::Error` into an `io::Error`, preserving the
112/// underlying I/O error kind (e.g. `BrokenPipe`) when present.
113pub(crate) fn serde_io_error(e: serde_json::Error) -> io::Error {
114    match e.io_error_kind() {
115        Some(kind) => io::Error::new(kind, e),
116        None => io::Error::other(e),
117    }
118}
119
120#[cfg(test)]
121pub(crate) mod test_helpers {
122    use crate::graph::types::*;
123
124    pub fn make_node(unique_id: &str, label: &str, node_type: NodeType) -> NodeData {
125        NodeData {
126            unique_id: unique_id.into(),
127            label: label.into(),
128            node_type,
129            file_path: None,
130            description: None,
131            materialization: None,
132            tags: vec![],
133            columns: vec![],
134            exposure: None,
135            aliases: vec![],
136        }
137    }
138
139    pub fn make_node_with_columns(
140        unique_id: &str,
141        label: &str,
142        node_type: NodeType,
143        columns: &[&str],
144    ) -> NodeData {
145        NodeData {
146            unique_id: unique_id.into(),
147            label: label.into(),
148            node_type,
149            file_path: None,
150            description: None,
151            materialization: None,
152            tags: vec![],
153            columns: columns.iter().map(|s| s.to_string()).collect(),
154            exposure: None,
155            aliases: vec![],
156        }
157    }
158
159    pub fn make_node_with_path(
160        unique_id: &str,
161        label: &str,
162        node_type: NodeType,
163        path: &str,
164    ) -> NodeData {
165        NodeData {
166            unique_id: unique_id.into(),
167            label: label.into(),
168            node_type,
169            file_path: Some(path.into()),
170            description: None,
171            materialization: None,
172            tags: vec![],
173            columns: vec![],
174            exposure: None,
175            aliases: vec![],
176        }
177    }
178
179    /// Build a representative lineage graph for snapshot tests:
180    /// source -> staging -> mart -> test, mart -> exposure
181    pub fn make_sample_lineage_graph() -> LineageGraph {
182        let mut graph = LineageGraph::new();
183        let src = graph.add_node(make_node(
184            "source.raw.orders",
185            "raw.orders",
186            NodeType::Source,
187        ));
188        let stg = graph.add_node(make_node("model.stg_orders", "stg_orders", NodeType::Model));
189        let mart = graph.add_node(make_node("model.orders", "orders", NodeType::Model));
190        let t = graph.add_node(make_node(
191            "test.orders_positive",
192            "orders_positive",
193            NodeType::Test,
194        ));
195        let exp = graph.add_node(make_node(
196            "exposure.dashboard",
197            "dashboard",
198            NodeType::Exposure,
199        ));
200
201        graph.add_edge(src, stg, EdgeData::direct(EdgeType::Source));
202        graph.add_edge(stg, mart, EdgeData::direct(EdgeType::Ref));
203        graph.add_edge(mart, t, EdgeData::direct(EdgeType::Test));
204        graph.add_edge(mart, exp, EdgeData::direct(EdgeType::Exposure));
205
206        graph
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_dot_escape_plain() {
216        assert_eq!(dot_escape("hello"), "hello");
217    }
218
219    #[test]
220    fn test_dot_escape_double_quote() {
221        assert_eq!(dot_escape(r#"a "b" c"#), r#"a \"b\" c"#);
222    }
223
224    #[test]
225    fn test_dot_escape_backslash() {
226        assert_eq!(dot_escape(r"a\b"), r"a\\b");
227    }
228
229    #[test]
230    fn test_dot_escape_both() {
231        assert_eq!(dot_escape(r#"a\"b"#), r#"a\\\"b"#);
232    }
233
234    #[test]
235    fn test_dot_escape_control_chars() {
236        assert_eq!(dot_escape("a\nb"), r"a\nb");
237        assert_eq!(dot_escape("a\rb"), r"a\rb");
238        assert_eq!(dot_escape("a\tb"), r"a\tb");
239    }
240
241    #[test]
242    fn test_sanitize_id_directory_with_hyphens() {
243        assert_eq!(sanitize_id("models/my-project"), "models_my_project");
244    }
245
246    #[test]
247    fn test_sanitize_id_parentheses() {
248        assert_eq!(sanitize_id("(other)"), "_other_");
249    }
250
251    #[test]
252    fn test_sanitize_id_empty() {
253        assert_eq!(sanitize_id(""), "");
254    }
255
256    #[test]
257    fn test_sanitize_id_already_valid() {
258        assert_eq!(sanitize_id("models_staging"), "models_staging");
259    }
260
261    #[test]
262    fn test_sanitize_id_non_ascii() {
263        assert_eq!(sanitize_id("モデル/日本語"), "_______");
264    }
265
266    #[test]
267    fn test_capitalize() {
268        assert_eq!(capitalize("model"), "Model");
269        assert_eq!(capitalize(""), "");
270    }
271
272    #[test]
273    fn test_directory_label_with_path() {
274        let node = test_helpers::make_node_with_path(
275            "model.a",
276            "a",
277            crate::graph::types::NodeType::Model,
278            "models/staging/a.sql",
279        );
280        assert_eq!(directory_label(&node), "models/staging");
281    }
282
283    #[test]
284    fn test_directory_label_without_path() {
285        let node =
286            test_helpers::make_node("exposure.e", "e", crate::graph::types::NodeType::Exposure);
287        assert_eq!(directory_label(&node), NO_DIRECTORY_LABEL);
288    }
289
290    #[test]
291    fn test_directory_label_file_at_root() {
292        let node = test_helpers::make_node_with_path(
293            "model.a",
294            "a",
295            crate::graph::types::NodeType::Model,
296            "a.sql",
297        );
298        assert_eq!(directory_label(&node), NO_DIRECTORY_LABEL);
299    }
300}