Skip to main content

dlin_core/render/
mod.rs

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