1use std::collections::{HashMap, HashSet};
2use std::io::{IsTerminal, Write};
3
4use path_slash::PathExt as _;
5use petgraph::visit::{EdgeRef, IntoEdgeReferences};
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::graph::types::*;
10
11pub const GRAPH_NODE_FIELDS: &[&str] = &[
13 "unique_id",
14 "label",
15 "node_type",
16 "file_path",
17 "description",
18 "materialization",
19 "tags",
20 "columns",
21 "sql_content",
22 "exposure",
23];
24
25pub const GRAPH_DEFAULT_FIELDS: &[&str] = &["unique_id", "label", "node_type", "file_path"];
27
28pub fn resolve_graph_fields(
31 json_fields: Option<&[String]>,
32 json_full: bool,
33) -> Result<HashSet<String>, String> {
34 if json_full {
35 return Ok(GRAPH_NODE_FIELDS.iter().map(|s| (*s).to_string()).collect());
36 }
37 match json_fields {
38 Some(fields) => {
39 let known: HashSet<&str> = GRAPH_NODE_FIELDS.iter().copied().collect();
40 let mut unknown: Vec<&str> = Vec::new();
41 for f in fields {
42 if !known.contains(f.as_str()) {
43 unknown.push(f);
44 }
45 }
46 if !unknown.is_empty() {
47 return Err(format!(
48 "unknown JSON field(s): {}. Available fields: {}",
49 unknown.join(", "),
50 GRAPH_NODE_FIELDS.join(", "),
51 ));
52 }
53 Ok(fields.iter().cloned().collect())
54 }
55 None => Ok(GRAPH_DEFAULT_FIELDS
56 .iter()
57 .map(|s| (*s).to_string())
58 .collect()),
59 }
60}
61
62#[derive(Serialize)]
63struct JsonGraph {
64 nodes: Vec<Value>,
65 edges: Vec<JsonEdge>,
66}
67
68#[derive(Serialize)]
69struct JsonEdge {
70 source: String,
71 target: String,
72 edge_type: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 collapsed_through: Option<usize>,
75}
76
77pub fn build_node_value(
79 node: &NodeData,
80 fields: &HashSet<String>,
81 sql_contents: Option<&HashMap<String, String>>,
82) -> Value {
83 let mut map = serde_json::Map::new();
84 if fields.contains("unique_id") {
85 map.insert("unique_id".into(), Value::String(node.unique_id.clone()));
86 }
87 if fields.contains("label") {
88 map.insert("label".into(), Value::String(node.label.clone()));
89 }
90 if fields.contains("node_type") {
91 map.insert(
92 "node_type".into(),
93 Value::String(node.node_type.label().to_string()),
94 );
95 }
96 if fields.contains("file_path") {
97 map.insert(
98 "file_path".into(),
99 match node.file_path {
100 Some(ref p) => Value::String(p.to_slash_lossy().into_owned()),
101 None => Value::Null,
102 },
103 );
104 }
105 if fields.contains("description") {
106 map.insert(
107 "description".into(),
108 match node.description {
109 Some(ref d) => Value::String(d.clone()),
110 None => Value::Null,
111 },
112 );
113 }
114 if fields.contains("materialization") {
115 map.insert(
116 "materialization".into(),
117 match node.materialization {
118 Some(ref m) => Value::String(m.clone()),
119 None => Value::Null,
120 },
121 );
122 }
123 if fields.contains("tags") {
124 map.insert(
125 "tags".into(),
126 Value::Array(node.tags.iter().map(|t| Value::String(t.clone())).collect()),
127 );
128 }
129 if fields.contains("columns") {
130 map.insert(
131 "columns".into(),
132 Value::Array(
133 node.columns
134 .iter()
135 .map(|c| Value::String(c.clone()))
136 .collect(),
137 ),
138 );
139 }
140 if fields.contains("sql_content") {
141 map.insert(
142 "sql_content".into(),
143 match sql_contents.and_then(|m| m.get(&node.unique_id)) {
144 Some(sql) => Value::String(sql.clone()),
145 None => Value::Null,
146 },
147 );
148 }
149 if fields.contains("exposure") {
150 let opt_str = |v: &Option<String>| -> Value {
151 v.as_ref().map_or(Value::Null, |s| Value::String(s.clone()))
152 };
153 map.insert(
154 "exposure".into(),
155 match node.exposure {
156 Some(ref exp) => {
157 let mut exp_map = serde_json::Map::new();
158 exp_map.insert("label".into(), opt_str(&exp.label));
159 exp_map.insert("type".into(), opt_str(&exp.exposure_type));
160 exp_map.insert("url".into(), opt_str(&exp.url));
161 exp_map.insert("maturity".into(), opt_str(&exp.maturity));
162 exp_map.insert(
163 "owner".into(),
164 match exp.owner {
165 Some(ref o) => {
166 let mut owner_map = serde_json::Map::new();
167 owner_map.insert("name".into(), opt_str(&o.name));
168 owner_map.insert("email".into(), opt_str(&o.email));
169 Value::Object(owner_map)
170 }
171 None => Value::Null,
172 },
173 );
174 Value::Object(exp_map)
175 }
176 None => Value::Null,
177 },
178 );
179 }
180 Value::Object(map)
181}
182
183pub fn render_json(
186 graph: &LineageGraph,
187 sql_contents: Option<&HashMap<String, String>>,
188 fields: &HashSet<String>,
189) {
190 let mut stdout = std::io::stdout().lock();
191 let pretty = stdout.is_terminal();
192 super::handle_stdout_result(render_json_to_writer(
193 graph,
194 sql_contents,
195 fields,
196 &mut stdout,
197 pretty,
198 ));
199}
200
201pub fn render_json_to_writer<W: Write>(
203 graph: &LineageGraph,
204 sql_contents: Option<&HashMap<String, String>>,
205 fields: &HashSet<String>,
206 w: &mut W,
207 pretty: bool,
208) -> std::io::Result<()> {
209 let mut nodes: Vec<(String, Value)> = graph
210 .node_indices()
211 .map(|idx| {
212 let node = &graph[idx];
213 let sort_key = node.unique_id.clone();
214 let value = build_node_value(node, fields, sql_contents);
215 (sort_key, value)
216 })
217 .collect();
218 nodes.sort_unstable_by(|a, b| a.0.cmp(&b.0));
219 let nodes: Vec<Value> = nodes.into_iter().map(|(_, v)| v).collect();
220
221 let mut edges: Vec<JsonEdge> = graph
222 .edge_references()
223 .map(|edge| {
224 let source = &graph[edge.source()];
225 let target = &graph[edge.target()];
226 JsonEdge {
227 source: source.unique_id.clone(),
228 target: target.unique_id.clone(),
229 edge_type: edge.weight().edge_type.label().to_string(),
230 collapsed_through: edge.weight().collapsed_through,
231 }
232 })
233 .collect();
234 edges.sort_unstable_by(|a, b| {
235 a.source
236 .cmp(&b.source)
237 .then(a.target.cmp(&b.target))
238 .then(a.edge_type.cmp(&b.edge_type))
239 });
240
241 let json_graph = JsonGraph { nodes, edges };
242 if pretty {
243 serde_json::to_writer_pretty(&mut *w, &json_graph).map_err(super::serde_io_error)?;
244 } else {
245 serde_json::to_writer(&mut *w, &json_graph).map_err(super::serde_io_error)?;
246 }
247 writeln!(w)?;
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::path::PathBuf;
255
256 use crate::render::test_helpers::make_node;
257
258 fn all_fields() -> HashSet<String> {
259 GRAPH_NODE_FIELDS.iter().map(|s| (*s).to_string()).collect()
260 }
261
262 fn render_to_string(graph: &LineageGraph) -> String {
263 let mut buf = Vec::new();
264 render_json_to_writer(graph, None, &all_fields(), &mut buf, true).unwrap();
265 String::from_utf8(buf).unwrap()
266 }
267
268 #[test]
269 fn test_empty_graph() {
270 let graph = LineageGraph::new();
271 let output = render_to_string(&graph);
272 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
273 assert_eq!(parsed["nodes"].as_array().unwrap().len(), 0);
274 assert_eq!(parsed["edges"].as_array().unwrap().len(), 0);
275 }
276
277 #[test]
278 fn test_single_node() {
279 let mut graph = LineageGraph::new();
280 graph.add_node(make_node("model.orders", "orders", NodeType::Model));
281 let output = render_to_string(&graph);
282 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
283 let nodes = parsed["nodes"].as_array().unwrap();
284 assert_eq!(nodes.len(), 1);
285 assert_eq!(nodes[0]["unique_id"], "model.orders");
286 assert_eq!(nodes[0]["label"], "orders");
287 assert_eq!(nodes[0]["node_type"], "model");
288 assert!(nodes[0]["file_path"].is_null());
289 assert!(nodes[0]["description"].is_null());
290 }
291
292 #[test]
293 fn test_node_with_file_path_and_description() {
294 let mut graph = LineageGraph::new();
295 graph.add_node(NodeData {
296 unique_id: "model.orders".into(),
297 label: "orders".into(),
298 node_type: NodeType::Model,
299 file_path: Some(PathBuf::from("models/orders.sql")),
300 description: Some("Orders mart model".into()),
301 materialization: None,
302 tags: vec![],
303 columns: vec![],
304 exposure: None,
305 aliases: vec![],
306 });
307 let output = render_to_string(&graph);
308 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
309 let nodes = parsed["nodes"].as_array().unwrap();
310 assert_eq!(nodes[0]["file_path"], "models/orders.sql");
311 assert_eq!(nodes[0]["description"], "Orders mart model");
312 }
313
314 #[test]
315 fn test_edges() {
316 let mut graph = LineageGraph::new();
317 let a = graph.add_node(make_node(
318 "source.raw.orders",
319 "raw.orders",
320 NodeType::Source,
321 ));
322 let b = graph.add_node(make_node("model.stg_orders", "stg_orders", NodeType::Model));
323 graph.add_edge(a, b, EdgeData::direct(EdgeType::Source));
324
325 let output = render_to_string(&graph);
326 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
327 let edges = parsed["edges"].as_array().unwrap();
328 assert_eq!(edges.len(), 1);
329 assert_eq!(edges[0]["source"], "source.raw.orders");
330 assert_eq!(edges[0]["target"], "model.stg_orders");
331 assert_eq!(edges[0]["edge_type"], "source");
332 }
333
334 #[test]
335 fn test_all_edge_types() {
336 assert_eq!(EdgeType::Ref.label(), "ref");
337 assert_eq!(EdgeType::Source.label(), "source");
338 assert_eq!(EdgeType::Test.label(), "test");
339 assert_eq!(EdgeType::Exposure.label(), "exposure");
340 }
341
342 #[test]
343 fn test_all_node_types() {
344 let mut graph = LineageGraph::new();
345 let types = [
346 ("model.a", NodeType::Model, "model"),
347 ("source.a.b", NodeType::Source, "source"),
348 ("seed.a", NodeType::Seed, "seed"),
349 ("snapshot.a", NodeType::Snapshot, "snapshot"),
350 ("test.a", NodeType::Test, "test"),
351 ("exposure.a", NodeType::Exposure, "exposure"),
352 ("model.unknown", NodeType::Phantom, "phantom"),
353 ];
354 for (id, nt, _) in &types {
355 graph.add_node(make_node(id, "a", *nt));
356 }
357 let output = render_to_string(&graph);
358 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
359 let nodes = parsed["nodes"].as_array().unwrap();
360 let mut actual: Vec<(&str, &str)> = nodes
362 .iter()
363 .map(|n| {
364 (
365 n["unique_id"].as_str().unwrap(),
366 n["node_type"].as_str().unwrap(),
367 )
368 })
369 .collect();
370 actual.sort();
371 let mut expected: Vec<(&str, &str)> = types.iter().map(|(id, _, t)| (*id, *t)).collect();
372 expected.sort();
373 assert_eq!(actual, expected);
374 }
375
376 #[test]
377 fn test_deterministic_node_order() {
378 let mut graph = LineageGraph::new();
379 graph.add_node(make_node("model.z_last", "z_last", NodeType::Model));
381 graph.add_node(make_node("model.a_first", "a_first", NodeType::Model));
382 graph.add_node(make_node("model.m_middle", "m_middle", NodeType::Model));
383 let output = render_to_string(&graph);
384 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
385 let nodes = parsed["nodes"].as_array().unwrap();
386 assert_eq!(nodes[0]["unique_id"], "model.a_first");
387 assert_eq!(nodes[1]["unique_id"], "model.m_middle");
388 assert_eq!(nodes[2]["unique_id"], "model.z_last");
389 }
390
391 #[test]
392 fn test_deterministic_edge_order() {
393 let mut graph = LineageGraph::new();
394 let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
395 let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
396 let c = graph.add_node(make_node("model.c", "c", NodeType::Model));
397 graph.add_edge(c, a, EdgeData::direct(EdgeType::Ref));
399 graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
400 graph.add_edge(a, c, EdgeData::direct(EdgeType::Ref));
401 let output = render_to_string(&graph);
402 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
403 let edges = parsed["edges"].as_array().unwrap();
404 assert_eq!(edges[0]["source"], "model.a");
406 assert_eq!(edges[0]["target"], "model.b");
407 assert_eq!(edges[1]["source"], "model.a");
408 assert_eq!(edges[1]["target"], "model.c");
409 assert_eq!(edges[2]["source"], "model.c");
410 assert_eq!(edges[2]["target"], "model.a");
411 }
412
413 #[test]
414 fn test_valid_json() {
415 let mut graph = LineageGraph::new();
416 let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
417 let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
418 graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
419 let output = render_to_string(&graph);
420 let _: serde_json::Value = serde_json::from_str(&output).unwrap();
422 }
423
424 #[test]
425 fn test_snapshot_lineage() {
426 let graph = crate::render::test_helpers::make_sample_lineage_graph();
427 let output = render_to_string(&graph);
428 insta::assert_snapshot!(output);
429 }
430
431 #[test]
432 fn test_snapshot_node_metadata() {
433 let mut graph = LineageGraph::new();
434 graph.add_node(NodeData {
435 unique_id: "model.orders".into(),
436 label: "orders".into(),
437 node_type: NodeType::Model,
438 file_path: Some(PathBuf::from("models/orders.sql")),
439 description: Some("Orders mart model".into()),
440 materialization: Some("table".into()),
441 tags: vec!["daily".into(), "core".into()],
442 columns: vec!["order_id".into(), "customer_id".into()],
443 exposure: None,
444 aliases: vec![],
445 });
446 let output = render_to_string(&graph);
447 insta::assert_snapshot!(output);
448 }
449
450 #[test]
451 fn test_snapshot_json_with_sql() {
452 let mut graph = LineageGraph::new();
453 graph.add_node(NodeData {
454 unique_id: "model.orders".into(),
455 label: "orders".into(),
456 node_type: NodeType::Model,
457 file_path: Some(PathBuf::from("models/orders.sql")),
458 description: None,
459 materialization: Some("table".into()),
460 tags: vec![],
461 columns: vec![],
462 exposure: None,
463 aliases: vec![],
464 });
465 graph.add_node(make_node(
466 "source.raw.orders",
467 "raw.orders",
468 NodeType::Source,
469 ));
470 let sql_contents = HashMap::from([(
471 "model.orders".to_string(),
472 "SELECT * FROM {{ ref('stg_orders') }}".to_string(),
473 )]);
474 let mut buf = Vec::new();
475 render_json_to_writer(&graph, Some(&sql_contents), &all_fields(), &mut buf, true).unwrap();
476 let output = String::from_utf8(buf).unwrap();
477 insta::assert_snapshot!(output);
478 }
479
480 #[test]
481 fn test_compact_json_single_line() {
482 let mut graph = LineageGraph::new();
483 let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
484 let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
485 graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
486 let mut buf = Vec::new();
487 render_json_to_writer(&graph, None, &all_fields(), &mut buf, false).unwrap();
488 let output = String::from_utf8(buf).unwrap();
489 let lines: Vec<&str> = output.trim_end().split('\n').collect();
490 assert_eq!(lines.len(), 1, "compact JSON should be a single line");
491 let _: serde_json::Value = serde_json::from_str(&output).unwrap();
492 }
493
494 #[test]
495 fn test_node_with_materialization_tags_columns() {
496 let mut graph = LineageGraph::new();
497 graph.add_node(NodeData {
498 unique_id: "model.orders".into(),
499 label: "orders".into(),
500 node_type: NodeType::Model,
501 file_path: None,
502 description: None,
503 materialization: Some("table".into()),
504 tags: vec!["daily".into(), "core".into()],
505 columns: vec!["order_id".into(), "customer_id".into()],
506 exposure: None,
507 aliases: vec![],
508 });
509 let output = render_to_string(&graph);
510 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
511 let node = &parsed["nodes"][0];
512 assert_eq!(node["materialization"], "table");
513 assert_eq!(node["tags"][0], "daily");
514 assert_eq!(node["tags"][1], "core");
515 assert_eq!(node["columns"][0], "order_id");
516 assert_eq!(node["columns"][1], "customer_id");
517 }
518
519 #[test]
520 fn test_transitive_edge_has_collapsed_through() {
521 let mut graph = LineageGraph::new();
522 let a = graph.add_node(make_node("source.raw.a", "a", NodeType::Source));
523 let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
524 graph.add_edge(a, b, EdgeData::transitive(EdgeType::Source, 2));
525
526 let output = render_to_string(&graph);
527 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
528 let edges = parsed["edges"].as_array().unwrap();
529 assert_eq!(edges.len(), 1);
530 assert_eq!(edges[0]["edge_type"], "source");
531 assert_eq!(edges[0]["collapsed_through"], 2);
532 }
533
534 #[test]
535 fn test_direct_edge_omits_collapsed_through() {
536 let mut graph = LineageGraph::new();
537 let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
538 let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
539 graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
540
541 let output = render_to_string(&graph);
542 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
543 let edges = parsed["edges"].as_array().unwrap();
544 assert!(edges[0].get("collapsed_through").is_none());
545 }
546
547 #[test]
550 fn test_default_fields_only() {
551 let mut graph = LineageGraph::new();
552 graph.add_node(NodeData {
553 unique_id: "model.orders".into(),
554 label: "orders".into(),
555 node_type: NodeType::Model,
556 file_path: Some(PathBuf::from("models/orders.sql")),
557 description: Some("desc".into()),
558 materialization: Some("table".into()),
559 tags: vec!["daily".into()],
560 columns: vec!["id".into()],
561 exposure: None,
562 aliases: vec![],
563 });
564 let fields = resolve_graph_fields(None, false).unwrap();
565 let mut buf = Vec::new();
566 render_json_to_writer(&graph, None, &fields, &mut buf, false).unwrap();
567 let output = String::from_utf8(buf).unwrap();
568 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
569 let node = &parsed["nodes"][0];
570 assert_eq!(node["unique_id"], "model.orders");
572 assert_eq!(node["label"], "orders");
573 assert_eq!(node["node_type"], "model");
574 assert_eq!(node["file_path"], "models/orders.sql");
575 assert!(node.get("description").is_none());
577 assert!(node.get("materialization").is_none());
578 assert!(node.get("tags").is_none());
579 assert!(node.get("columns").is_none());
580 assert!(node.get("exposure").is_none());
581 }
582
583 #[test]
584 fn test_custom_fields() {
585 let mut graph = LineageGraph::new();
586 graph.add_node(NodeData {
587 unique_id: "model.orders".into(),
588 label: "orders".into(),
589 node_type: NodeType::Model,
590 file_path: Some(PathBuf::from("models/orders.sql")),
591 description: Some("desc".into()),
592 materialization: Some("table".into()),
593 tags: vec![],
594 columns: vec![],
595 exposure: None,
596 aliases: vec![],
597 });
598 let fields =
599 resolve_graph_fields(Some(&["unique_id".into(), "description".into()]), false).unwrap();
600 let mut buf = Vec::new();
601 render_json_to_writer(&graph, None, &fields, &mut buf, false).unwrap();
602 let output = String::from_utf8(buf).unwrap();
603 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
604 let node = &parsed["nodes"][0];
605 assert_eq!(node["unique_id"], "model.orders");
606 assert_eq!(node["description"], "desc");
607 assert!(node.get("label").is_none());
609 assert!(node.get("node_type").is_none());
610 assert!(node.get("file_path").is_none());
611 }
612
613 #[test]
614 fn test_json_full_includes_all() {
615 let mut graph = LineageGraph::new();
616 graph.add_node(NodeData {
617 unique_id: "model.orders".into(),
618 label: "orders".into(),
619 node_type: NodeType::Model,
620 file_path: Some(PathBuf::from("models/orders.sql")),
621 description: Some("desc".into()),
622 materialization: Some("table".into()),
623 tags: vec!["daily".into()],
624 columns: vec!["id".into()],
625 exposure: None,
626 aliases: vec![],
627 });
628 let fields = resolve_graph_fields(None, true).unwrap();
629 let mut buf = Vec::new();
630 render_json_to_writer(&graph, None, &fields, &mut buf, false).unwrap();
631 let output = String::from_utf8(buf).unwrap();
632 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
633 let node = &parsed["nodes"][0];
634 assert_eq!(node["description"], "desc");
635 assert_eq!(node["materialization"], "table");
636 assert_eq!(node["tags"][0], "daily");
637 assert_eq!(node["columns"][0], "id");
638 }
639
640 #[test]
641 fn test_unknown_field_error() {
642 let result = resolve_graph_fields(Some(&["unique_id".into(), "nonexistent".into()]), false);
643 assert!(result.is_err());
644 let err = result.unwrap_err();
645 assert!(err.contains("nonexistent"));
646 assert!(err.contains("Available fields"));
647 }
648
649 #[test]
650 fn test_exposure_fields_in_json() {
651 let mut graph = LineageGraph::new();
652 graph.add_node(NodeData {
653 unique_id: "exposure.dashboard".into(),
654 label: "dashboard".into(),
655 node_type: NodeType::Exposure,
656 file_path: None,
657 description: Some("Main dashboard".into()),
658 materialization: None,
659 tags: vec![],
660 columns: vec![],
661 exposure: Some(ExposureInfo {
662 label: Some("Main Dashboard".into()),
663 exposure_type: Some("dashboard".into()),
664 url: Some("https://bi.example.com".into()),
665 maturity: Some("high".into()),
666 owner: Some(OwnerInfo {
667 name: Some("Data Team".into()),
668 email: Some("data@example.com".into()),
669 }),
670 }),
671 aliases: vec![],
672 });
673
674 let fields = resolve_graph_fields(None, true).unwrap();
675 let mut buf = Vec::new();
676 render_json_to_writer(&graph, None, &fields, &mut buf, true).unwrap();
677 let output = String::from_utf8(buf).unwrap();
678 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
679 let node = &parsed["nodes"][0];
680
681 let exposure = &node["exposure"];
682 assert_eq!(exposure["label"], "Main Dashboard");
683 assert_eq!(exposure["type"], "dashboard");
684 assert_eq!(exposure["url"], "https://bi.example.com");
685 assert_eq!(exposure["maturity"], "high");
686 assert_eq!(exposure["owner"]["name"], "Data Team");
687 assert_eq!(exposure["owner"]["email"], "data@example.com");
688 }
689
690 #[test]
691 fn test_exposure_null_for_non_exposure_nodes() {
692 let mut graph = LineageGraph::new();
693 graph.add_node(make_node("model.orders", "orders", NodeType::Model));
694
695 let fields = resolve_graph_fields(None, true).unwrap();
696 let mut buf = Vec::new();
697 render_json_to_writer(&graph, None, &fields, &mut buf, true).unwrap();
698 let output = String::from_utf8(buf).unwrap();
699 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
700 let node = &parsed["nodes"][0];
701
702 assert!(node["exposure"].is_null());
703 }
704}