Skip to main content

dlin_core/render/
json.rs

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
11/// All available node fields for graph JSON output.
12pub 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
25/// Default node fields when neither --json-fields nor --json-full is specified.
26pub const GRAPH_DEFAULT_FIELDS: &[&str] = &["unique_id", "label", "node_type", "file_path"];
27
28/// Resolve which fields to emit, and validate field names.
29/// Returns `Err` with a message listing available fields if any name is unknown.
30pub 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
77/// Build a JSON object containing only the requested fields for a node.
78pub 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
183/// Render the lineage graph as JSON to stdout.
184/// Pretty-prints when stdout is a terminal, compact otherwise.
185pub 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
201/// Render the lineage graph as JSON to the given writer.
202pub 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        // Nodes are sorted by unique_id; verify all expected types are present
361        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        // Add nodes in reverse alphabetical order
380        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        // Add edges in reverse order
398        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        // Sorted by (source, target)
405        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        // Should parse as valid JSON
421        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    // -- Field filtering tests ------------------------------------------------
548
549    #[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        // Default fields present
571        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        // Non-default fields absent (not in default set)
576        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        // Other fields absent
608        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}