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 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
91pub(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 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}