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
17pub(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
27pub(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
36pub(crate) const NO_DIRECTORY_LABEL: &str = "(other)";
38
39pub(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 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
59pub(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
78pub(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
98pub(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
111pub(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 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}