Skip to main content

dlin_core/parser/
manifest.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::Path;
3
4use anyhow::Result;
5use petgraph::stable_graph::NodeIndex;
6use serde::Deserialize;
7
8use crate::graph::types::*;
9
10/// Metadata section of manifest.json
11#[derive(Debug, Default, Deserialize)]
12pub struct ManifestMetadata {
13    pub project_name: Option<String>,
14    /// dbt adapter type (e.g. "postgres", "bigquery", "snowflake") — present in dbt >=1.x manifests.
15    pub adapter_type: Option<String>,
16}
17
18/// Top-level manifest.json structure
19#[derive(Debug, Default, Deserialize)]
20pub struct Manifest {
21    /// Metadata about the manifest (dbt version, project name, etc.)
22    #[serde(default)]
23    pub metadata: ManifestMetadata,
24    /// Nodes keyed by unique_id (models, seeds, snapshots, tests, analyses)
25    #[serde(default)]
26    pub nodes: HashMap<String, ManifestNode>,
27    /// Sources keyed by unique_id
28    #[serde(default)]
29    pub sources: HashMap<String, ManifestSource>,
30    /// Exposures keyed by unique_id
31    #[serde(default)]
32    pub exposures: HashMap<String, ManifestExposure>,
33    /// Semantic models keyed by unique_id (dbt Semantic Layer)
34    #[serde(default)]
35    pub semantic_models: HashMap<String, ManifestSemanticModel>,
36    /// Metrics keyed by unique_id (dbt Semantic Layer)
37    #[serde(default)]
38    pub metrics: HashMap<String, ManifestMetric>,
39    /// Saved queries keyed by unique_id (dbt Semantic Layer)
40    #[serde(default)]
41    pub saved_queries: HashMap<String, ManifestSavedQuery>,
42}
43
44/// A node entry in the manifest (model, seed, snapshot, test, analysis)
45#[derive(Debug, Deserialize)]
46pub struct ManifestNode {
47    pub unique_id: String,
48    pub name: String,
49    pub resource_type: String,
50    #[serde(default)]
51    pub depends_on: DependsOn,
52    #[serde(default)]
53    pub config: ManifestConfig,
54    pub description: Option<String>,
55    pub path: Option<String>,
56    /// Project-root-relative path (e.g. "models/staging/stg_orders.sql").
57    /// Present in dbt >=1.x manifests; preferred over `path` for file matching.
58    pub original_file_path: Option<String>,
59    /// Column definitions keyed by column name
60    #[serde(default)]
61    pub columns: HashMap<String, ManifestColumn>,
62    /// Compiled SQL code (Jinja resolved) — present after `dbt compile` or `dbt run`
63    pub compiled_code: Option<String>,
64    /// Database name (e.g., "jaffle_shop")
65    #[serde(default)]
66    pub database: Option<String>,
67    /// Schema name (e.g., "main")
68    #[serde(default)]
69    pub schema: Option<String>,
70}
71
72/// A source entry in the manifest
73#[derive(Debug, Deserialize)]
74pub struct ManifestSource {
75    pub unique_id: String,
76    pub name: String,
77    pub source_name: String,
78    #[serde(default)]
79    pub resource_type: String,
80    pub description: Option<String>,
81    pub path: Option<String>,
82    /// Project-root-relative path; preferred over `path` for file matching.
83    pub original_file_path: Option<String>,
84    /// Column definitions keyed by column name
85    #[serde(default)]
86    pub columns: HashMap<String, ManifestColumn>,
87    /// Physical database name (may differ from source_name)
88    #[serde(default)]
89    pub database: Option<String>,
90    /// Physical schema name (may differ from source_name)
91    #[serde(default)]
92    pub schema: Option<String>,
93    /// Physical table identifier (defaults to name when absent)
94    #[serde(default)]
95    pub identifier: Option<String>,
96}
97
98/// A column entry in the manifest
99#[derive(Debug, Deserialize)]
100pub struct ManifestColumn {
101    pub name: String,
102}
103
104/// An exposure entry in the manifest
105#[derive(Debug, Deserialize)]
106pub struct ManifestExposure {
107    pub unique_id: String,
108    pub name: String,
109    #[serde(default)]
110    pub depends_on: DependsOn,
111    pub description: Option<String>,
112    pub label: Option<String>,
113    #[serde(rename = "type")]
114    pub exposure_type: Option<String>,
115    pub url: Option<String>,
116    pub maturity: Option<String>,
117    pub owner: Option<ManifestExposureOwner>,
118}
119
120/// Owner information in a manifest exposure entry
121#[derive(Debug, Deserialize)]
122pub struct ManifestExposureOwner {
123    pub name: Option<String>,
124    pub email: Option<String>,
125}
126
127/// A semantic model entry in the manifest (dbt Semantic Layer)
128#[derive(Debug, Deserialize)]
129pub struct ManifestSemanticModel {
130    pub unique_id: String,
131    pub name: String,
132    pub label: Option<String>,
133    #[serde(default)]
134    pub depends_on: DependsOn,
135    pub description: Option<String>,
136    pub path: Option<String>,
137    pub original_file_path: Option<String>,
138}
139
140/// A metric entry in the manifest (dbt Semantic Layer)
141#[derive(Debug, Deserialize)]
142pub struct ManifestMetric {
143    pub unique_id: String,
144    pub name: String,
145    pub label: Option<String>,
146    #[serde(default)]
147    pub depends_on: DependsOn,
148    pub description: Option<String>,
149    pub path: Option<String>,
150    pub original_file_path: Option<String>,
151}
152
153/// A saved query entry in the manifest (dbt Semantic Layer)
154#[derive(Debug, Deserialize)]
155pub struct ManifestSavedQuery {
156    pub unique_id: String,
157    pub name: String,
158    pub label: Option<String>,
159    #[serde(default)]
160    pub depends_on: DependsOn,
161    pub description: Option<String>,
162    pub path: Option<String>,
163    pub original_file_path: Option<String>,
164}
165
166/// depends_on section with a list of node unique_ids
167#[derive(Debug, Default, Deserialize)]
168pub struct DependsOn {
169    #[serde(default)]
170    pub nodes: Vec<String>,
171}
172
173/// Config section for nodes
174#[derive(Debug, Default, Deserialize)]
175pub struct ManifestConfig {
176    pub materialized: Option<String>,
177    #[serde(default)]
178    pub tags: Vec<String>,
179}
180
181/// Map a manifest resource_type string to our NodeType enum
182fn resource_type_to_node_type(resource_type: &str) -> NodeType {
183    match resource_type {
184        "model" => NodeType::Model,
185        "source" => NodeType::Source,
186        "seed" => NodeType::Seed,
187        "snapshot" => NodeType::Snapshot,
188        "test" => NodeType::Test,
189        "analysis" => NodeType::Model,
190        "exposure" => NodeType::Exposure,
191        _ => NodeType::Model,
192    }
193}
194
195/// Simplify a dbt manifest unique_id (e.g. "model.my_project.stg_orders") to
196/// the short form used in this tool's graph (e.g. "model.stg_orders").
197/// For sources: "source.my_project.raw.orders" -> "source.raw.orders"
198/// For tests:   "test.my_project.test_name.hash" -> "test.test_name"
199fn simplify_unique_id(unique_id: &str, resource_type: &str) -> String {
200    let parts: Vec<&str> = unique_id.split('.').collect();
201    match resource_type {
202        "source" => {
203            // source.project.source_name.table_name -> source.source_name.table_name
204            if parts.len() >= 4 {
205                format!("{}.{}.{}", parts[0], parts[2], parts[3])
206            } else {
207                unique_id.to_string()
208            }
209        }
210        "test" => {
211            // test.project.test_name[.hash] -> test.test_name (skip trailing hash)
212            if parts.len() >= 3 {
213                format!("{}.{}", parts[0], parts[2])
214            } else {
215                unique_id.to_string()
216            }
217        }
218        _ => {
219            // model.project.name → model.name
220            // model.project.name.v1 → model.name.v1  (versioned models)
221            if parts.len() >= 3 {
222                format!("{}.{}", parts[0], parts[2..].join("."))
223            } else {
224                unique_id.to_string()
225            }
226        }
227    }
228}
229
230/// Load and parse a manifest.json file without building a graph.
231pub fn load_manifest(manifest_path: &Path) -> Result<Manifest> {
232    let content =
233        std::fs::read(manifest_path).map_err(|e| crate::error::DbtLineageError::FileReadError {
234            path: manifest_path.to_path_buf(),
235            source: e,
236        })?;
237
238    load_manifest_from_bytes(&content, manifest_path)
239}
240
241pub fn load_manifest_from_bytes(content: &[u8], manifest_path: &Path) -> Result<Manifest> {
242    let manifest: Manifest = serde_json::from_slice(content).map_err(|e| {
243        crate::error::DbtLineageError::ArtifactParseError {
244            path: manifest_path.to_path_buf(),
245            source: e,
246        }
247    })?;
248
249    Ok(manifest)
250}
251
252impl Manifest {
253    /// Collect `compiled_code` from manifest nodes as a mapping from simplified
254    /// unique_id to SQL string.  Nodes without `compiled_code` are omitted.
255    ///
256    /// This is the manifest-mode counterpart of the file-based
257    /// `collect_sql_contents` used in SQL-parse mode.  Users must run
258    /// `dbt compile` (or `dbt run`) before invoking dlin so that the manifest
259    /// contains compiled SQL.
260    pub fn collect_sql_contents(&self) -> HashMap<String, String> {
261        let mut map = HashMap::new();
262        for (orig_id, node) in &self.nodes {
263            if let Some(ref code) = node.compiled_code {
264                let simple_id = simplify_unique_id(orig_id, &node.resource_type);
265                map.insert(simple_id, code.clone());
266            }
267        }
268        map
269    }
270
271    /// Collect all unique file paths referenced by nodes and sources.
272    /// Returns relative paths as stored in the manifest (e.g. "models/staging/stg_orders.sql").
273    pub fn collect_file_paths(&self) -> BTreeSet<String> {
274        let mut paths = BTreeSet::new();
275        for node in self.nodes.values() {
276            let p = node.original_file_path.as_ref().or(node.path.as_ref());
277            if let Some(p) = p {
278                paths.insert(p.clone());
279            }
280        }
281        for source in self.sources.values() {
282            let p = source.original_file_path.as_ref().or(source.path.as_ref());
283            if let Some(p) = p {
284                paths.insert(p.clone());
285            }
286        }
287        for sm in self.semantic_models.values() {
288            let p = sm.original_file_path.as_ref().or(sm.path.as_ref());
289            if let Some(p) = p {
290                paths.insert(p.clone());
291            }
292        }
293        for metric in self.metrics.values() {
294            let p = metric.original_file_path.as_ref().or(metric.path.as_ref());
295            if let Some(p) = p {
296                paths.insert(p.clone());
297            }
298        }
299        for sq in self.saved_queries.values() {
300            let p = sq.original_file_path.as_ref().or(sq.path.as_ref());
301            if let Some(p) = p {
302                paths.insert(p.clone());
303            }
304        }
305        paths
306    }
307}
308
309/// Build a LineageGraph from a parsed manifest.json file.
310pub fn build_graph_from_manifest(manifest_path: &Path) -> Result<LineageGraph> {
311    let manifest = load_manifest(manifest_path)?;
312    build_graph_from_parsed_manifest(&manifest)
313}
314
315/// Build a LineageGraph from an already-parsed Manifest struct.
316/// This is separated for testability and reuse by the diff feature.
317pub fn build_graph_from_parsed_manifest(manifest: &Manifest) -> Result<LineageGraph> {
318    let mut graph = LineageGraph::new();
319    // Map from original manifest unique_id to graph NodeIndex
320    let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
321
322    // 1. Add source nodes
323    add_source_nodes(&mut graph, &mut node_map, &manifest.sources);
324
325    // 2. Add regular nodes (models, seeds, snapshots, tests, analyses)
326    add_regular_nodes(&mut graph, &mut node_map, &manifest.nodes);
327
328    // 3. Add exposure nodes
329    add_exposure_nodes(&mut graph, &mut node_map, &manifest.exposures);
330
331    // 4. Add semantic layer nodes
332    add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.semantic_models);
333    add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.metrics);
334    add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.saved_queries);
335
336    // 5. Add edges from depends_on for regular nodes
337    add_node_edges(&mut graph, &node_map, &manifest.nodes);
338
339    // 6. Add edges from depends_on for exposures
340    add_exposure_edges(&mut graph, &node_map, &manifest.exposures);
341
342    // 7. Add edges from depends_on for semantic layer nodes
343    add_depends_on_edges(&mut graph, &node_map, &manifest.semantic_models);
344    add_depends_on_edges(&mut graph, &node_map, &manifest.metrics);
345    add_depends_on_edges(&mut graph, &node_map, &manifest.saved_queries);
346
347    Ok(graph)
348}
349
350fn add_source_nodes(
351    graph: &mut LineageGraph,
352    node_map: &mut HashMap<String, NodeIndex>,
353    sources: &HashMap<String, ManifestSource>,
354) {
355    for (orig_id, source) in sources {
356        let simple_id = simplify_unique_id(orig_id, "source");
357        let label = format!("{}.{}", source.source_name, source.name);
358
359        let idx = graph.add_node(NodeData {
360            unique_id: simple_id.clone(),
361            label,
362            node_type: NodeType::Source,
363            file_path: source
364                .original_file_path
365                .as_ref()
366                .or(source.path.as_ref())
367                .map(|p| p.into()),
368            description: non_empty_string(&source.description),
369            materialization: None,
370            tags: vec![],
371            columns: {
372                let mut cols: Vec<String> = source.columns.keys().cloned().collect();
373                cols.sort();
374                cols
375            },
376            exposure: None,
377            aliases: vec![],
378        });
379        node_map.insert(orig_id.clone(), idx);
380        // Also index by simplified id for edge resolution
381        node_map.insert(simple_id, idx);
382    }
383}
384
385fn add_regular_nodes(
386    graph: &mut LineageGraph,
387    node_map: &mut HashMap<String, NodeIndex>,
388    nodes: &HashMap<String, ManifestNode>,
389) {
390    for (orig_id, node) in nodes {
391        let node_type = resource_type_to_node_type(&node.resource_type);
392        let simple_id = simplify_unique_id(orig_id, &node.resource_type);
393
394        let idx = graph.add_node(NodeData {
395            unique_id: simple_id.clone(),
396            label: node.name.clone(),
397            node_type,
398            file_path: node
399                .original_file_path
400                .as_ref()
401                .or(node.path.as_ref())
402                .map(|p| p.into()),
403            description: non_empty_string(&node.description),
404            materialization: node.config.materialized.clone(),
405            tags: node.config.tags.clone(),
406            columns: {
407                let mut cols: Vec<String> = node.columns.keys().cloned().collect();
408                cols.sort();
409                cols
410            },
411            exposure: None,
412            aliases: vec![],
413        });
414        node_map.insert(orig_id.clone(), idx);
415        node_map.insert(simple_id, idx);
416    }
417}
418
419fn add_exposure_nodes(
420    graph: &mut LineageGraph,
421    node_map: &mut HashMap<String, NodeIndex>,
422    exposures: &HashMap<String, ManifestExposure>,
423) {
424    for (orig_id, exposure) in exposures {
425        let simple_id = simplify_unique_id(orig_id, "exposure");
426
427        let idx = graph.add_node(NodeData {
428            unique_id: simple_id.clone(),
429            label: exposure.name.clone(),
430            node_type: NodeType::Exposure,
431            file_path: None,
432            description: non_empty_string(&exposure.description),
433            materialization: None,
434            tags: vec![],
435            columns: vec![],
436            exposure: Some(ExposureInfo {
437                label: non_empty_string(&exposure.label),
438                exposure_type: non_empty_string(&exposure.exposure_type),
439                url: non_empty_string(&exposure.url),
440                maturity: non_empty_string(&exposure.maturity),
441                owner: exposure.owner.as_ref().map(|o| OwnerInfo {
442                    name: non_empty_string(&o.name),
443                    email: non_empty_string(&o.email),
444                }),
445            }),
446            aliases: vec![],
447        });
448        node_map.insert(orig_id.clone(), idx);
449        node_map.insert(simple_id, idx);
450    }
451}
452
453fn add_node_edges(
454    graph: &mut LineageGraph,
455    node_map: &HashMap<String, NodeIndex>,
456    nodes: &HashMap<String, ManifestNode>,
457) {
458    for (orig_id, node) in nodes {
459        let current_idx = match node_map.get(orig_id) {
460            Some(&idx) => idx,
461            None => continue,
462        };
463
464        // Use EdgeType::Test when the target node is a test, regardless of
465        // the dependency's type prefix, so all test relationships are consistent.
466        let current_is_test = graph[current_idx].node_type == NodeType::Test;
467
468        for dep_id in &node.depends_on.nodes {
469            if let Some(&dep_idx) = node_map.get(dep_id) {
470                let edge_type = if current_is_test {
471                    EdgeType::Test
472                } else {
473                    infer_edge_type(dep_id)
474                };
475                graph.add_edge(dep_idx, current_idx, EdgeData::direct(edge_type));
476            }
477        }
478    }
479}
480
481fn add_exposure_edges(
482    graph: &mut LineageGraph,
483    node_map: &HashMap<String, NodeIndex>,
484    exposures: &HashMap<String, ManifestExposure>,
485) {
486    for (orig_id, exposure) in exposures {
487        let current_idx = match node_map.get(orig_id) {
488            Some(&idx) => idx,
489            None => continue,
490        };
491
492        for dep_id in &exposure.depends_on.nodes {
493            if let Some(&dep_idx) = node_map.get(dep_id) {
494                graph.add_edge(dep_idx, current_idx, EdgeData::direct(EdgeType::Exposure));
495            }
496        }
497    }
498}
499
500trait HasSemanticLayerFields {
501    fn name(&self) -> &str;
502    fn label(&self) -> Option<&str>;
503    fn depends_on_nodes(&self) -> &[String];
504    fn description(&self) -> Option<&str>;
505    fn original_file_path(&self) -> Option<&str>;
506    fn path(&self) -> Option<&str>;
507    fn node_type(&self) -> NodeType;
508}
509
510impl HasSemanticLayerFields for ManifestSemanticModel {
511    fn name(&self) -> &str {
512        &self.name
513    }
514    fn label(&self) -> Option<&str> {
515        self.label.as_deref()
516    }
517    fn depends_on_nodes(&self) -> &[String] {
518        &self.depends_on.nodes
519    }
520    fn description(&self) -> Option<&str> {
521        self.description.as_deref()
522    }
523    fn original_file_path(&self) -> Option<&str> {
524        self.original_file_path.as_deref()
525    }
526    fn path(&self) -> Option<&str> {
527        self.path.as_deref()
528    }
529    fn node_type(&self) -> NodeType {
530        NodeType::SemanticModel
531    }
532}
533
534impl HasSemanticLayerFields for ManifestMetric {
535    fn name(&self) -> &str {
536        &self.name
537    }
538    fn label(&self) -> Option<&str> {
539        self.label.as_deref()
540    }
541    fn depends_on_nodes(&self) -> &[String] {
542        &self.depends_on.nodes
543    }
544    fn description(&self) -> Option<&str> {
545        self.description.as_deref()
546    }
547    fn original_file_path(&self) -> Option<&str> {
548        self.original_file_path.as_deref()
549    }
550    fn path(&self) -> Option<&str> {
551        self.path.as_deref()
552    }
553    fn node_type(&self) -> NodeType {
554        NodeType::Metric
555    }
556}
557
558impl HasSemanticLayerFields for ManifestSavedQuery {
559    fn name(&self) -> &str {
560        &self.name
561    }
562    fn label(&self) -> Option<&str> {
563        self.label.as_deref()
564    }
565    fn depends_on_nodes(&self) -> &[String] {
566        &self.depends_on.nodes
567    }
568    fn description(&self) -> Option<&str> {
569        self.description.as_deref()
570    }
571    fn original_file_path(&self) -> Option<&str> {
572        self.original_file_path.as_deref()
573    }
574    fn path(&self) -> Option<&str> {
575        self.path.as_deref()
576    }
577    fn node_type(&self) -> NodeType {
578        NodeType::SavedQuery
579    }
580}
581
582fn add_semantic_layer_nodes<T: HasSemanticLayerFields>(
583    graph: &mut LineageGraph,
584    node_map: &mut HashMap<String, NodeIndex>,
585    items: &HashMap<String, T>,
586) {
587    for (orig_id, item) in items {
588        let resource_type = item.node_type().label();
589        let simple_id = simplify_unique_id(orig_id, resource_type);
590        let idx = graph.add_node(NodeData {
591            unique_id: simple_id.clone(),
592            label: item.label().unwrap_or_else(|| item.name()).to_string(),
593            node_type: item.node_type(),
594            file_path: item
595                .original_file_path()
596                .or_else(|| item.path())
597                .map(|p| p.into()),
598            description: item
599                .description()
600                .filter(|s| !s.trim().is_empty())
601                .map(str::to_string),
602            materialization: None,
603            tags: vec![],
604            columns: vec![],
605            exposure: None,
606            aliases: vec![],
607        });
608        node_map.insert(orig_id.clone(), idx);
609        node_map.insert(simple_id, idx);
610    }
611}
612
613fn add_depends_on_edges<T: HasSemanticLayerFields>(
614    graph: &mut LineageGraph,
615    node_map: &HashMap<String, NodeIndex>,
616    items: &HashMap<String, T>,
617) {
618    for (orig_id, item) in items {
619        let Some(&current_idx) = node_map.get(orig_id) else {
620            continue;
621        };
622        for dep_id in item.depends_on_nodes() {
623            if let Some(&dep_idx) = node_map.get(dep_id) {
624                graph.add_edge(
625                    dep_idx,
626                    current_idx,
627                    EdgeData::direct(infer_edge_type(dep_id)),
628                );
629            }
630        }
631    }
632}
633
634/// Infer the edge type from a dependency unique_id
635fn infer_edge_type(dep_unique_id: &str) -> EdgeType {
636    if dep_unique_id.starts_with("source.") {
637        EdgeType::Source
638    } else if dep_unique_id.starts_with("test.") {
639        EdgeType::Test
640    } else {
641        EdgeType::Ref
642    }
643}
644
645/// Return None for empty or whitespace-only strings
646fn non_empty_string(s: &Option<String>) -> Option<String> {
647    s.as_ref().filter(|v| !v.trim().is_empty()).cloned()
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use std::fs;
654
655    #[test]
656    fn test_resource_type_to_node_type() {
657        assert_eq!(resource_type_to_node_type("model"), NodeType::Model);
658        assert_eq!(resource_type_to_node_type("source"), NodeType::Source);
659        assert_eq!(resource_type_to_node_type("seed"), NodeType::Seed);
660        assert_eq!(resource_type_to_node_type("snapshot"), NodeType::Snapshot);
661        assert_eq!(resource_type_to_node_type("test"), NodeType::Test);
662        assert_eq!(resource_type_to_node_type("analysis"), NodeType::Model);
663        assert_eq!(resource_type_to_node_type("exposure"), NodeType::Exposure);
664        assert_eq!(resource_type_to_node_type("unknown"), NodeType::Model);
665    }
666
667    #[test]
668    fn test_simplify_unique_id_model() {
669        assert_eq!(
670            simplify_unique_id("model.my_project.stg_orders", "model"),
671            "model.stg_orders"
672        );
673    }
674
675    #[test]
676    fn test_simplify_unique_id_source() {
677        assert_eq!(
678            simplify_unique_id("source.my_project.raw.orders", "source"),
679            "source.raw.orders"
680        );
681    }
682
683    #[test]
684    fn test_simplify_unique_id_short() {
685        assert_eq!(
686            simplify_unique_id("model.stg_orders", "model"),
687            "model.stg_orders"
688        );
689    }
690
691    #[test]
692    fn test_simplify_unique_id_source_short() {
693        assert_eq!(
694            simplify_unique_id("source.raw.orders", "source"),
695            "source.raw.orders"
696        );
697    }
698
699    #[test]
700    fn test_simplify_unique_id_test() {
701        // test.project.test_name.hash -> test.test_name
702        assert_eq!(
703            simplify_unique_id(
704                "test.jaffle_shop.not_null_orders_order_id.cf6c17daed",
705                "test"
706            ),
707            "test.not_null_orders_order_id"
708        );
709    }
710
711    #[test]
712    fn test_simplify_unique_id_test_short() {
713        assert_eq!(
714            simplify_unique_id("test.not_null_orders_order_id", "test"),
715            "test.not_null_orders_order_id"
716        );
717    }
718
719    #[test]
720    fn test_simplify_unique_id_versioned_model() {
721        // dbt versioned model unique_ids: model.project.name.v{N} → model.name.v{N}
722        assert_eq!(
723            simplify_unique_id("model.my_project.my_model.v1", "model"),
724            "model.my_model.v1"
725        );
726        assert_eq!(
727            simplify_unique_id("model.my_project.my_model.v2", "model"),
728            "model.my_model.v2"
729        );
730        // Unversioned model must still work
731        assert_eq!(
732            simplify_unique_id("model.my_project.stg_orders", "model"),
733            "model.stg_orders"
734        );
735    }
736
737    #[test]
738    fn test_infer_edge_type() {
739        assert_eq!(
740            infer_edge_type("source.my_project.raw.orders"),
741            EdgeType::Source
742        );
743        assert_eq!(
744            infer_edge_type("model.my_project.stg_orders"),
745            EdgeType::Ref
746        );
747        assert_eq!(infer_edge_type("test.my_project.some_test"), EdgeType::Test);
748        assert_eq!(infer_edge_type("seed.my_project.countries"), EdgeType::Ref);
749    }
750
751    #[test]
752    fn test_non_empty_string() {
753        assert_eq!(non_empty_string(&None), None);
754        assert_eq!(non_empty_string(&Some("".to_string())), None);
755        assert_eq!(non_empty_string(&Some("  ".to_string())), None);
756        assert_eq!(
757            non_empty_string(&Some("hello".to_string())),
758            Some("hello".to_string())
759        );
760    }
761
762    #[test]
763    fn test_build_graph_from_minimal_manifest() {
764        let manifest = Manifest {
765            nodes: HashMap::from([(
766                "model.proj.stg_orders".to_string(),
767                ManifestNode {
768                    unique_id: "model.proj.stg_orders".to_string(),
769                    name: "stg_orders".to_string(),
770                    resource_type: "model".to_string(),
771                    depends_on: DependsOn {
772                        nodes: vec!["source.proj.raw.orders".to_string()],
773                    },
774                    config: ManifestConfig {
775                        materialized: Some("view".to_string()),
776                        tags: vec!["staging".to_string()],
777                    },
778                    description: Some("Staged orders".to_string()),
779                    path: Some("models/staging/stg_orders.sql".to_string()),
780                    original_file_path: None,
781                    columns: HashMap::new(),
782                    compiled_code: None,
783                    database: None,
784                    schema: None,
785                },
786            )]),
787            sources: HashMap::from([(
788                "source.proj.raw.orders".to_string(),
789                ManifestSource {
790                    unique_id: "source.proj.raw.orders".to_string(),
791                    name: "orders".to_string(),
792                    source_name: "raw".to_string(),
793                    resource_type: "source".to_string(),
794                    description: Some("Raw orders table".to_string()),
795                    path: Some("models/staging/schema.yml".to_string()),
796                    original_file_path: None,
797                    columns: HashMap::new(),
798                    database: None,
799                    schema: None,
800                    identifier: None,
801                },
802            )]),
803            ..Default::default()
804        };
805
806        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
807
808        assert_eq!(graph.node_count(), 2);
809        assert_eq!(graph.edge_count(), 1);
810
811        // Find the model node
812        let model = graph
813            .node_indices()
814            .find(|&i| graph[i].node_type == NodeType::Model)
815            .expect("Should have a model node");
816        assert_eq!(graph[model].label, "stg_orders");
817        assert_eq!(graph[model].unique_id, "model.stg_orders");
818        assert_eq!(graph[model].materialization.as_deref(), Some("view"));
819        assert_eq!(graph[model].tags, vec!["staging"]);
820        assert_eq!(graph[model].description.as_deref(), Some("Staged orders"));
821
822        // Find the source node
823        let source = graph
824            .node_indices()
825            .find(|&i| graph[i].node_type == NodeType::Source)
826            .expect("Should have a source node");
827        assert_eq!(graph[source].label, "raw.orders");
828        assert_eq!(graph[source].unique_id, "source.raw.orders");
829    }
830
831    #[test]
832    fn test_build_graph_with_exposures() {
833        let manifest = Manifest {
834            nodes: HashMap::from([(
835                "model.proj.orders".to_string(),
836                ManifestNode {
837                    unique_id: "model.proj.orders".to_string(),
838                    name: "orders".to_string(),
839                    resource_type: "model".to_string(),
840                    depends_on: DependsOn::default(),
841                    config: ManifestConfig::default(),
842                    description: None,
843                    path: None,
844                    original_file_path: None,
845                    columns: HashMap::new(),
846                    compiled_code: None,
847                    database: None,
848                    schema: None,
849                },
850            )]),
851            sources: HashMap::new(),
852            exposures: HashMap::from([(
853                "exposure.proj.weekly_report".to_string(),
854                ManifestExposure {
855                    unique_id: "exposure.proj.weekly_report".to_string(),
856                    name: "weekly_report".to_string(),
857                    depends_on: DependsOn {
858                        nodes: vec!["model.proj.orders".to_string()],
859                    },
860                    description: Some("Weekly dashboard".to_string()),
861                    label: None,
862                    exposure_type: None,
863                    url: None,
864                    maturity: None,
865                    owner: None,
866                },
867            )]),
868            ..Default::default()
869        };
870
871        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
872        assert_eq!(graph.node_count(), 2);
873        assert_eq!(graph.edge_count(), 1);
874
875        let exposure = graph
876            .node_indices()
877            .find(|&i| graph[i].node_type == NodeType::Exposure)
878            .expect("Should have an exposure node");
879        assert_eq!(graph[exposure].label, "weekly_report");
880        assert_eq!(
881            graph[exposure].description.as_deref(),
882            Some("Weekly dashboard")
883        );
884    }
885
886    #[test]
887    fn test_exposure_metadata_parsed() {
888        let manifest = Manifest {
889            nodes: HashMap::new(),
890            sources: HashMap::new(),
891            exposures: HashMap::from([(
892                "exposure.proj.dashboard".to_string(),
893                ManifestExposure {
894                    unique_id: "exposure.proj.dashboard".to_string(),
895                    name: "dashboard".to_string(),
896                    depends_on: DependsOn { nodes: vec![] },
897                    description: Some("Main dashboard".to_string()),
898                    label: Some("Main Dashboard".to_string()),
899                    exposure_type: Some("dashboard".to_string()),
900                    url: Some("https://bi.example.com".to_string()),
901                    maturity: Some("high".to_string()),
902                    owner: Some(ManifestExposureOwner {
903                        name: Some("Data Team".to_string()),
904                        email: Some("data@example.com".to_string()),
905                    }),
906                },
907            )]),
908            ..Default::default()
909        };
910
911        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
912        let exp_idx = graph
913            .node_indices()
914            .find(|&i| graph[i].node_type == NodeType::Exposure)
915            .expect("Should have an exposure node");
916        let exp = &graph[exp_idx];
917
918        let info = exp.exposure.as_ref().expect("Should have exposure info");
919        assert_eq!(info.label.as_deref(), Some("Main Dashboard"));
920        assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
921        assert_eq!(info.url.as_deref(), Some("https://bi.example.com"));
922        assert_eq!(info.maturity.as_deref(), Some("high"));
923
924        let owner = info.owner.as_ref().expect("Should have owner");
925        assert_eq!(owner.name.as_deref(), Some("Data Team"));
926        assert_eq!(owner.email.as_deref(), Some("data@example.com"));
927    }
928
929    #[test]
930    fn test_exposure_metadata_from_fixture() {
931        let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
932            .join("../../tests/fixtures/simple_project/target/manifest.json");
933        let graph = build_graph_from_manifest(&manifest_path).unwrap();
934
935        let exp_idx = graph
936            .node_indices()
937            .find(|&i| graph[i].node_type == NodeType::Exposure)
938            .expect("Should have an exposure node from fixture");
939        let exp = &graph[exp_idx];
940        assert_eq!(exp.label, "weekly_report");
941
942        let info = exp.exposure.as_ref().expect("Should have exposure info");
943        assert_eq!(info.label.as_deref(), Some("Weekly Report"));
944        assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
945        assert_eq!(info.url.as_deref(), Some("https://bi.example.com/weekly"));
946        assert_eq!(info.maturity.as_deref(), Some("high"));
947
948        let owner = info.owner.as_ref().expect("Should have owner");
949        assert_eq!(owner.name.as_deref(), Some("Data Team"));
950        assert_eq!(owner.email.as_deref(), Some("data@example.com"));
951    }
952
953    #[test]
954    fn test_build_graph_with_seeds_and_snapshots() {
955        let manifest = Manifest {
956            nodes: HashMap::from([
957                (
958                    "seed.proj.countries".to_string(),
959                    ManifestNode {
960                        unique_id: "seed.proj.countries".to_string(),
961                        name: "countries".to_string(),
962                        resource_type: "seed".to_string(),
963                        depends_on: DependsOn::default(),
964                        config: ManifestConfig::default(),
965                        description: None,
966                        path: Some("seeds/countries.csv".to_string()),
967                        original_file_path: None,
968                        columns: HashMap::new(),
969                        compiled_code: None,
970                        database: None,
971                        schema: None,
972                    },
973                ),
974                (
975                    "snapshot.proj.snap_orders".to_string(),
976                    ManifestNode {
977                        unique_id: "snapshot.proj.snap_orders".to_string(),
978                        name: "snap_orders".to_string(),
979                        resource_type: "snapshot".to_string(),
980                        depends_on: DependsOn::default(),
981                        config: ManifestConfig {
982                            materialized: Some("snapshot".to_string()),
983                            tags: vec![],
984                        },
985                        description: None,
986                        path: Some("snapshots/snap_orders.sql".to_string()),
987                        original_file_path: None,
988                        columns: HashMap::new(),
989                        compiled_code: None,
990                        database: None,
991                        schema: None,
992                    },
993                ),
994            ]),
995            sources: HashMap::new(),
996            ..Default::default()
997        };
998
999        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1000        assert_eq!(graph.node_count(), 2);
1001
1002        let seed = graph
1003            .node_indices()
1004            .find(|&i| graph[i].node_type == NodeType::Seed)
1005            .expect("Should have a seed node");
1006        assert_eq!(graph[seed].label, "countries");
1007
1008        let snap = graph
1009            .node_indices()
1010            .find(|&i| graph[i].node_type == NodeType::Snapshot)
1011            .expect("Should have a snapshot node");
1012        assert_eq!(graph[snap].label, "snap_orders");
1013    }
1014
1015    #[test]
1016    fn test_build_graph_with_tests() {
1017        let manifest = Manifest {
1018            nodes: HashMap::from([
1019                (
1020                    "model.proj.orders".to_string(),
1021                    ManifestNode {
1022                        unique_id: "model.proj.orders".to_string(),
1023                        name: "orders".to_string(),
1024                        resource_type: "model".to_string(),
1025                        depends_on: DependsOn::default(),
1026                        config: ManifestConfig::default(),
1027                        description: None,
1028                        path: None,
1029                        original_file_path: None,
1030                        columns: HashMap::new(),
1031                        compiled_code: None,
1032                        database: None,
1033                        schema: None,
1034                    },
1035                ),
1036                (
1037                    "test.proj.assert_positive".to_string(),
1038                    ManifestNode {
1039                        unique_id: "test.proj.assert_positive".to_string(),
1040                        name: "assert_positive".to_string(),
1041                        resource_type: "test".to_string(),
1042                        depends_on: DependsOn {
1043                            nodes: vec!["model.proj.orders".to_string()],
1044                        },
1045                        config: ManifestConfig::default(),
1046                        description: None,
1047                        path: Some("tests/assert_positive.sql".to_string()),
1048                        original_file_path: None,
1049                        columns: HashMap::new(),
1050                        compiled_code: None,
1051                        database: None,
1052                        schema: None,
1053                    },
1054                ),
1055            ]),
1056            sources: HashMap::new(),
1057            ..Default::default()
1058        };
1059
1060        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1061        assert_eq!(graph.node_count(), 2);
1062        assert_eq!(graph.edge_count(), 1);
1063
1064        let test_node = graph
1065            .node_indices()
1066            .find(|&i| graph[i].node_type == NodeType::Test)
1067            .expect("Should have a test node");
1068        assert_eq!(graph[test_node].label, "assert_positive");
1069
1070        // Edge to test node should use EdgeType::Test, not EdgeType::Ref
1071        use petgraph::visit::IntoEdgeReferences;
1072        let edge = graph.edge_references().next().unwrap();
1073        assert_eq!(edge.weight().edge_type, EdgeType::Test);
1074    }
1075
1076    #[test]
1077    fn test_build_graph_empty_manifest() {
1078        let manifest = Manifest {
1079            nodes: HashMap::new(),
1080            sources: HashMap::new(),
1081            ..Default::default()
1082        };
1083
1084        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1085        assert_eq!(graph.node_count(), 0);
1086        assert_eq!(graph.edge_count(), 0);
1087    }
1088
1089    #[test]
1090    fn test_build_graph_missing_dependency() {
1091        // A node depends on something not in the manifest -- edge is skipped gracefully
1092        let manifest = Manifest {
1093            nodes: HashMap::from([(
1094                "model.proj.orders".to_string(),
1095                ManifestNode {
1096                    unique_id: "model.proj.orders".to_string(),
1097                    name: "orders".to_string(),
1098                    resource_type: "model".to_string(),
1099                    depends_on: DependsOn {
1100                        nodes: vec!["model.proj.nonexistent".to_string()],
1101                    },
1102                    config: ManifestConfig::default(),
1103                    description: None,
1104                    path: None,
1105                    original_file_path: None,
1106                    columns: HashMap::new(),
1107                    compiled_code: None,
1108                    database: None,
1109                    schema: None,
1110                },
1111            )]),
1112            sources: HashMap::new(),
1113            ..Default::default()
1114        };
1115
1116        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1117        assert_eq!(graph.node_count(), 1);
1118        assert_eq!(graph.edge_count(), 0); // Edge to nonexistent node is skipped
1119    }
1120
1121    #[test]
1122    fn test_build_graph_optional_fields() {
1123        let manifest = Manifest {
1124            nodes: HashMap::from([(
1125                "model.proj.bare".to_string(),
1126                ManifestNode {
1127                    unique_id: "model.proj.bare".to_string(),
1128                    name: "bare".to_string(),
1129                    resource_type: "model".to_string(),
1130                    depends_on: DependsOn::default(),
1131                    config: ManifestConfig {
1132                        materialized: None,
1133                        tags: vec![],
1134                    },
1135                    description: None,
1136                    path: None,
1137                    original_file_path: None,
1138                    columns: HashMap::new(),
1139                    compiled_code: None,
1140                    database: None,
1141                    schema: None,
1142                },
1143            )]),
1144            sources: HashMap::new(),
1145            ..Default::default()
1146        };
1147
1148        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1149        let node = &graph[graph.node_indices().next().unwrap()];
1150        assert!(node.description.is_none());
1151        assert!(node.materialization.is_none());
1152        assert!(node.tags.is_empty());
1153        assert!(node.file_path.is_none());
1154    }
1155
1156    #[test]
1157    fn test_build_graph_from_manifest_file() {
1158        let tmp = tempfile::tempdir().unwrap();
1159        let manifest_path = tmp.path().join("manifest.json");
1160
1161        let manifest_json = r#"{
1162            "nodes": {
1163                "model.proj.stg_orders": {
1164                    "unique_id": "model.proj.stg_orders",
1165                    "name": "stg_orders",
1166                    "resource_type": "model",
1167                    "depends_on": { "nodes": ["source.proj.raw.orders"] },
1168                    "config": { "materialized": "view", "tags": [] },
1169                    "description": "Staged orders",
1170                    "path": "models/staging/stg_orders.sql"
1171                }
1172            },
1173            "sources": {
1174                "source.proj.raw.orders": {
1175                    "unique_id": "source.proj.raw.orders",
1176                    "name": "orders",
1177                    "source_name": "raw",
1178                    "resource_type": "source",
1179                    "description": "Raw orders",
1180                    "path": "models/staging/schema.yml"
1181                }
1182            },
1183            "exposures": {}
1184        }"#;
1185
1186        fs::write(&manifest_path, manifest_json).unwrap();
1187
1188        let graph = build_graph_from_manifest(&manifest_path).unwrap();
1189        assert_eq!(graph.node_count(), 2);
1190        assert_eq!(graph.edge_count(), 1);
1191    }
1192
1193    #[test]
1194    fn test_build_graph_from_manifest_file_not_found() {
1195        let result = build_graph_from_manifest(Path::new("/nonexistent/manifest.json"));
1196        assert!(result.is_err());
1197    }
1198
1199    #[test]
1200    fn test_build_graph_from_manifest_invalid_json() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        let manifest_path = tmp.path().join("manifest.json");
1203        fs::write(&manifest_path, "not valid json").unwrap();
1204
1205        let result = build_graph_from_manifest(&manifest_path);
1206        assert!(result.is_err());
1207    }
1208
1209    #[test]
1210    fn test_original_file_path_preferred_over_path() {
1211        // dbt >= 1.x sets path to the models-dir-relative path (e.g. "staging/stg_orders.sql")
1212        // and original_file_path to the project-root-relative path ("models/staging/stg_orders.sql").
1213        // resolve_sql_to_label strips the project root and compares against file_path, so
1214        // original_file_path must win when both are present.
1215        let manifest = Manifest {
1216            nodes: HashMap::from([(
1217                "model.proj.stg_orders".to_string(),
1218                ManifestNode {
1219                    unique_id: "model.proj.stg_orders".to_string(),
1220                    name: "stg_orders".to_string(),
1221                    resource_type: "model".to_string(),
1222                    depends_on: DependsOn::default(),
1223                    config: ManifestConfig::default(),
1224                    description: None,
1225                    path: Some("staging/stg_orders.sql".to_string()),
1226                    original_file_path: Some("models/staging/stg_orders.sql".to_string()),
1227                    columns: HashMap::new(),
1228                    compiled_code: None,
1229                    database: None,
1230                    schema: None,
1231                },
1232            )]),
1233            sources: HashMap::new(),
1234            ..Default::default()
1235        };
1236
1237        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1238        let node = &graph[graph.node_indices().next().unwrap()];
1239        assert_eq!(
1240            node.file_path.as_ref().map(|p| p.to_str().unwrap()),
1241            Some("models/staging/stg_orders.sql")
1242        );
1243    }
1244
1245    #[test]
1246    fn test_build_graph_analysis_maps_to_model() {
1247        let manifest = Manifest {
1248            nodes: HashMap::from([(
1249                "analysis.proj.my_analysis".to_string(),
1250                ManifestNode {
1251                    unique_id: "analysis.proj.my_analysis".to_string(),
1252                    name: "my_analysis".to_string(),
1253                    resource_type: "analysis".to_string(),
1254                    depends_on: DependsOn::default(),
1255                    config: ManifestConfig::default(),
1256                    description: None,
1257                    path: None,
1258                    original_file_path: None,
1259                    columns: HashMap::new(),
1260                    compiled_code: None,
1261                    database: None,
1262                    schema: None,
1263                },
1264            )]),
1265            sources: HashMap::new(),
1266            ..Default::default()
1267        };
1268
1269        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1270        let node = &graph[graph.node_indices().next().unwrap()];
1271        assert_eq!(node.node_type, NodeType::Model);
1272    }
1273
1274    #[test]
1275    fn test_build_graph_complex_chain() {
1276        // source -> stg_orders -> orders (with multiple deps)
1277        let manifest = Manifest {
1278            nodes: HashMap::from([
1279                (
1280                    "model.proj.stg_orders".to_string(),
1281                    ManifestNode {
1282                        unique_id: "model.proj.stg_orders".to_string(),
1283                        name: "stg_orders".to_string(),
1284                        resource_type: "model".to_string(),
1285                        depends_on: DependsOn {
1286                            nodes: vec!["source.proj.raw.orders".to_string()],
1287                        },
1288                        config: ManifestConfig {
1289                            materialized: Some("view".to_string()),
1290                            tags: vec![],
1291                        },
1292                        description: None,
1293                        path: None,
1294                        original_file_path: None,
1295                        columns: HashMap::new(),
1296                        compiled_code: None,
1297                        database: None,
1298                        schema: None,
1299                    },
1300                ),
1301                (
1302                    "model.proj.stg_payments".to_string(),
1303                    ManifestNode {
1304                        unique_id: "model.proj.stg_payments".to_string(),
1305                        name: "stg_payments".to_string(),
1306                        resource_type: "model".to_string(),
1307                        depends_on: DependsOn {
1308                            nodes: vec!["source.proj.raw.payments".to_string()],
1309                        },
1310                        config: ManifestConfig::default(),
1311                        description: None,
1312                        path: None,
1313                        original_file_path: None,
1314                        columns: HashMap::new(),
1315                        compiled_code: None,
1316                        database: None,
1317                        schema: None,
1318                    },
1319                ),
1320                (
1321                    "model.proj.orders".to_string(),
1322                    ManifestNode {
1323                        unique_id: "model.proj.orders".to_string(),
1324                        name: "orders".to_string(),
1325                        resource_type: "model".to_string(),
1326                        depends_on: DependsOn {
1327                            nodes: vec![
1328                                "model.proj.stg_orders".to_string(),
1329                                "model.proj.stg_payments".to_string(),
1330                            ],
1331                        },
1332                        config: ManifestConfig {
1333                            materialized: Some("table".to_string()),
1334                            tags: vec!["marts".to_string()],
1335                        },
1336                        description: Some("Order fact table".to_string()),
1337                        path: None,
1338                        original_file_path: None,
1339                        columns: HashMap::new(),
1340                        compiled_code: None,
1341                        database: None,
1342                        schema: None,
1343                    },
1344                ),
1345            ]),
1346            sources: HashMap::from([
1347                (
1348                    "source.proj.raw.orders".to_string(),
1349                    ManifestSource {
1350                        unique_id: "source.proj.raw.orders".to_string(),
1351                        name: "orders".to_string(),
1352                        source_name: "raw".to_string(),
1353                        resource_type: "source".to_string(),
1354                        description: None,
1355                        path: None,
1356                        original_file_path: None,
1357                        columns: HashMap::new(),
1358                        database: None,
1359                        schema: None,
1360                        identifier: None,
1361                    },
1362                ),
1363                (
1364                    "source.proj.raw.payments".to_string(),
1365                    ManifestSource {
1366                        unique_id: "source.proj.raw.payments".to_string(),
1367                        name: "payments".to_string(),
1368                        source_name: "raw".to_string(),
1369                        resource_type: "source".to_string(),
1370                        description: None,
1371                        path: None,
1372                        original_file_path: None,
1373                        columns: HashMap::new(),
1374                        database: None,
1375                        schema: None,
1376                        identifier: None,
1377                    },
1378                ),
1379            ]),
1380            ..Default::default()
1381        };
1382
1383        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1384        // 2 sources + 3 models = 5 nodes
1385        assert_eq!(graph.node_count(), 5);
1386        // source.raw.orders -> stg_orders, source.raw.payments -> stg_payments,
1387        // stg_orders -> orders, stg_payments -> orders = 4 edges
1388        assert_eq!(graph.edge_count(), 4);
1389    }
1390
1391    #[test]
1392    fn test_build_graph_from_fixture_manifest() {
1393        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1394            .join("../../tests/fixtures/simple_project/target/manifest.json");
1395
1396        if !fixture_path.exists() {
1397            // Skip if fixture not yet created
1398            return;
1399        }
1400
1401        let graph = build_graph_from_manifest(&fixture_path).unwrap();
1402
1403        // The fixture has: 3 sources, 3 staging models, 2 mart models, 1 seed, 1 test, 1 exposure
1404        // = 11 nodes total
1405        assert!(
1406            graph.node_count() >= 10,
1407            "Expected at least 10 nodes, got {}",
1408            graph.node_count()
1409        );
1410
1411        // Check we have all node types present
1412        let has_source = graph
1413            .node_indices()
1414            .any(|i| graph[i].node_type == NodeType::Source);
1415        let has_model = graph
1416            .node_indices()
1417            .any(|i| graph[i].node_type == NodeType::Model);
1418        let has_seed = graph
1419            .node_indices()
1420            .any(|i| graph[i].node_type == NodeType::Seed);
1421        let has_test = graph
1422            .node_indices()
1423            .any(|i| graph[i].node_type == NodeType::Test);
1424        let has_exposure = graph
1425            .node_indices()
1426            .any(|i| graph[i].node_type == NodeType::Exposure);
1427
1428        assert!(has_source, "Should have source nodes");
1429        assert!(has_model, "Should have model nodes");
1430        assert!(has_seed, "Should have seed nodes");
1431        assert!(has_test, "Should have test nodes");
1432        assert!(has_exposure, "Should have exposure nodes");
1433
1434        // Check edges exist
1435        assert!(graph.edge_count() > 0, "Should have edges");
1436    }
1437
1438    #[test]
1439    fn test_collect_file_paths() {
1440        let manifest = Manifest {
1441            nodes: HashMap::from([
1442                (
1443                    "model.proj.stg_orders".to_string(),
1444                    ManifestNode {
1445                        unique_id: "model.proj.stg_orders".to_string(),
1446                        name: "stg_orders".to_string(),
1447                        resource_type: "model".to_string(),
1448                        depends_on: DependsOn::default(),
1449                        config: ManifestConfig::default(),
1450                        description: None,
1451                        path: Some("models/staging/stg_orders.sql".to_string()),
1452                        original_file_path: None,
1453                        columns: HashMap::new(),
1454                        compiled_code: None,
1455                        database: None,
1456                        schema: None,
1457                    },
1458                ),
1459                (
1460                    "model.proj.orders".to_string(),
1461                    ManifestNode {
1462                        unique_id: "model.proj.orders".to_string(),
1463                        name: "orders".to_string(),
1464                        resource_type: "model".to_string(),
1465                        depends_on: DependsOn::default(),
1466                        config: ManifestConfig::default(),
1467                        description: None,
1468                        path: Some("models/marts/orders.sql".to_string()),
1469                        original_file_path: None,
1470                        columns: HashMap::new(),
1471                        compiled_code: None,
1472                        database: None,
1473                        schema: None,
1474                    },
1475                ),
1476                (
1477                    "model.proj.bare".to_string(),
1478                    ManifestNode {
1479                        unique_id: "model.proj.bare".to_string(),
1480                        name: "bare".to_string(),
1481                        resource_type: "model".to_string(),
1482                        depends_on: DependsOn::default(),
1483                        config: ManifestConfig::default(),
1484                        description: None,
1485                        path: None,
1486                        original_file_path: None,
1487                        columns: HashMap::new(),
1488                        compiled_code: None,
1489                        database: None,
1490                        schema: None,
1491                    },
1492                ),
1493            ]),
1494            sources: HashMap::from([(
1495                "source.proj.raw.orders".to_string(),
1496                ManifestSource {
1497                    unique_id: "source.proj.raw.orders".to_string(),
1498                    name: "orders".to_string(),
1499                    source_name: "raw".to_string(),
1500                    resource_type: "source".to_string(),
1501                    description: None,
1502                    path: Some("models/staging/schema.yml".to_string()),
1503                    original_file_path: None,
1504                    columns: HashMap::new(),
1505                    database: None,
1506                    schema: None,
1507                    identifier: None,
1508                },
1509            )]),
1510            ..Default::default()
1511        };
1512
1513        let paths = manifest.collect_file_paths();
1514        assert_eq!(paths.len(), 3);
1515        assert!(paths.contains("models/staging/stg_orders.sql"));
1516        assert!(paths.contains("models/marts/orders.sql"));
1517        assert!(paths.contains("models/staging/schema.yml"));
1518        // bare has no path, should not appear
1519        assert!(!paths.iter().any(|p| p.contains("bare")));
1520    }
1521
1522    #[test]
1523    fn test_collect_file_paths_deduplicates() {
1524        // Multiple sources can reference the same YAML file
1525        let manifest = Manifest {
1526            nodes: HashMap::new(),
1527            sources: HashMap::from([
1528                (
1529                    "source.proj.raw.orders".to_string(),
1530                    ManifestSource {
1531                        unique_id: "source.proj.raw.orders".to_string(),
1532                        name: "orders".to_string(),
1533                        source_name: "raw".to_string(),
1534                        resource_type: "source".to_string(),
1535                        description: None,
1536                        path: Some("models/staging/schema.yml".to_string()),
1537                        original_file_path: None,
1538                        columns: HashMap::new(),
1539                        database: None,
1540                        schema: None,
1541                        identifier: None,
1542                    },
1543                ),
1544                (
1545                    "source.proj.raw.customers".to_string(),
1546                    ManifestSource {
1547                        unique_id: "source.proj.raw.customers".to_string(),
1548                        name: "customers".to_string(),
1549                        source_name: "raw".to_string(),
1550                        resource_type: "source".to_string(),
1551                        description: None,
1552                        path: Some("models/staging/schema.yml".to_string()),
1553                        original_file_path: None,
1554                        columns: HashMap::new(),
1555                        database: None,
1556                        schema: None,
1557                        identifier: None,
1558                    },
1559                ),
1560            ]),
1561            ..Default::default()
1562        };
1563
1564        let paths = manifest.collect_file_paths();
1565        assert_eq!(paths.len(), 1, "Duplicate paths should be deduplicated");
1566    }
1567
1568    #[test]
1569    fn test_load_manifest() {
1570        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1571            .join("../../tests/fixtures/simple_project/target/manifest.json");
1572
1573        let manifest = load_manifest(&fixture_path).unwrap();
1574        assert!(!manifest.nodes.is_empty());
1575        assert!(!manifest.sources.is_empty());
1576
1577        let paths = manifest.collect_file_paths();
1578        assert!(paths.contains("models/staging/stg_orders.sql"));
1579        assert!(paths.contains("models/staging/schema.yml"));
1580    }
1581
1582    #[test]
1583    fn test_collect_sql_contents_from_manifest() {
1584        let manifest = Manifest {
1585            nodes: HashMap::from([
1586                (
1587                    "model.proj.stg_orders".to_string(),
1588                    ManifestNode {
1589                        unique_id: "model.proj.stg_orders".to_string(),
1590                        name: "stg_orders".to_string(),
1591                        resource_type: "model".to_string(),
1592                        depends_on: DependsOn::default(),
1593                        config: ManifestConfig::default(),
1594                        description: None,
1595                        path: None,
1596                        original_file_path: None,
1597                        columns: HashMap::new(),
1598                        compiled_code: Some("select * from raw.orders".to_string()),
1599                        database: None,
1600                        schema: None,
1601                    },
1602                ),
1603                (
1604                    "test.proj.not_null_orders_id.abc123".to_string(),
1605                    ManifestNode {
1606                        unique_id: "test.proj.not_null_orders_id.abc123".to_string(),
1607                        name: "not_null_orders_id".to_string(),
1608                        resource_type: "test".to_string(),
1609                        depends_on: DependsOn::default(),
1610                        config: ManifestConfig::default(),
1611                        description: None,
1612                        path: None,
1613                        original_file_path: None,
1614                        columns: HashMap::new(),
1615                        compiled_code: Some(
1616                            "select count(*) from orders where id is null".to_string(),
1617                        ),
1618                        database: None,
1619                        schema: None,
1620                    },
1621                ),
1622                (
1623                    "model.proj.no_compile".to_string(),
1624                    ManifestNode {
1625                        unique_id: "model.proj.no_compile".to_string(),
1626                        name: "no_compile".to_string(),
1627                        resource_type: "model".to_string(),
1628                        depends_on: DependsOn::default(),
1629                        config: ManifestConfig::default(),
1630                        description: None,
1631                        path: None,
1632                        original_file_path: None,
1633                        columns: HashMap::new(),
1634                        compiled_code: None,
1635                        database: None,
1636                        schema: None,
1637                    },
1638                ),
1639            ]),
1640            sources: HashMap::new(),
1641            ..Default::default()
1642        };
1643
1644        let sql_contents = manifest.collect_sql_contents();
1645
1646        // compiled_code present → included
1647        assert_eq!(
1648            sql_contents.get("model.stg_orders").map(|s| s.as_str()),
1649            Some("select * from raw.orders")
1650        );
1651        // test unique_id is simplified (test.proj.name.hash → test.name)
1652        assert_eq!(
1653            sql_contents
1654                .get("test.not_null_orders_id")
1655                .map(|s| s.as_str()),
1656            Some("select count(*) from orders where id is null")
1657        );
1658        // compiled_code absent → omitted
1659        assert!(!sql_contents.contains_key("model.no_compile"));
1660    }
1661
1662    #[test]
1663    fn test_collect_sql_contents_from_fixture() {
1664        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1665            .join("../../tests/fixtures/simple_project/target/manifest.json");
1666
1667        let manifest = load_manifest(&fixture_path).unwrap();
1668        let sql_contents = manifest.collect_sql_contents();
1669
1670        // The fixture has compiled_code for stg_orders and the test node
1671        assert!(
1672            sql_contents.contains_key("model.stg_orders"),
1673            "stg_orders should have compiled_code"
1674        );
1675        assert!(
1676            sql_contents.contains_key("test.assert_orders_positive_amount"),
1677            "test node should have compiled_code"
1678        );
1679        // Nodes without compiled_code should not appear
1680        assert!(
1681            !sql_contents.contains_key("model.customers"),
1682            "customers has no compiled_code in fixture"
1683        );
1684    }
1685
1686    #[test]
1687    fn test_build_graph_with_semantic_layer_nodes() {
1688        let manifest = Manifest {
1689            nodes: HashMap::from([(
1690                "model.proj.orders".to_string(),
1691                ManifestNode {
1692                    unique_id: "model.proj.orders".to_string(),
1693                    name: "orders".to_string(),
1694                    resource_type: "model".to_string(),
1695                    depends_on: DependsOn::default(),
1696                    config: ManifestConfig::default(),
1697                    description: None,
1698                    path: None,
1699                    original_file_path: None,
1700                    columns: HashMap::new(),
1701                    compiled_code: None,
1702                    database: None,
1703                    schema: None,
1704                },
1705            )]),
1706            sources: HashMap::new(),
1707            semantic_models: HashMap::from([(
1708                "semantic_model.proj.orders".to_string(),
1709                ManifestSemanticModel {
1710                    unique_id: "semantic_model.proj.orders".to_string(),
1711                    name: "orders".to_string(),
1712                    label: None,
1713                    depends_on: DependsOn {
1714                        nodes: vec!["model.proj.orders".to_string()],
1715                    },
1716                    description: Some("Orders semantic model".to_string()),
1717                    path: None,
1718                    original_file_path: None,
1719                },
1720            )]),
1721            metrics: HashMap::from([(
1722                "metric.proj.order_count".to_string(),
1723                ManifestMetric {
1724                    unique_id: "metric.proj.order_count".to_string(),
1725                    name: "order_count".to_string(),
1726                    label: None,
1727                    depends_on: DependsOn {
1728                        nodes: vec!["semantic_model.proj.orders".to_string()],
1729                    },
1730                    description: None,
1731                    path: None,
1732                    original_file_path: None,
1733                },
1734            )]),
1735            saved_queries: HashMap::from([(
1736                "saved_query.proj.order_metrics".to_string(),
1737                ManifestSavedQuery {
1738                    unique_id: "saved_query.proj.order_metrics".to_string(),
1739                    name: "order_metrics".to_string(),
1740                    label: None,
1741                    depends_on: DependsOn {
1742                        nodes: vec!["metric.proj.order_count".to_string()],
1743                    },
1744                    description: None,
1745                    path: None,
1746                    original_file_path: None,
1747                },
1748            )]),
1749            ..Default::default()
1750        };
1751
1752        let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1753
1754        // 4 nodes: model + semantic_model + metric + saved_query
1755        assert_eq!(graph.node_count(), 4);
1756        // 3 edges: model->sem, sem->metric, metric->saved_query
1757        assert_eq!(graph.edge_count(), 3);
1758
1759        let sem = graph
1760            .node_indices()
1761            .find(|&i| graph[i].node_type == NodeType::SemanticModel)
1762            .expect("Should have a semantic_model node");
1763        assert_eq!(graph[sem].unique_id, "semantic_model.orders");
1764        assert_eq!(graph[sem].label, "orders");
1765        assert_eq!(
1766            graph[sem].description.as_deref(),
1767            Some("Orders semantic model")
1768        );
1769
1770        let metric = graph
1771            .node_indices()
1772            .find(|&i| graph[i].node_type == NodeType::Metric)
1773            .expect("Should have a metric node");
1774        assert_eq!(graph[metric].unique_id, "metric.order_count");
1775
1776        let sq = graph
1777            .node_indices()
1778            .find(|&i| graph[i].node_type == NodeType::SavedQuery)
1779            .expect("Should have a saved_query node");
1780        assert_eq!(graph[sq].unique_id, "saved_query.order_metrics");
1781    }
1782
1783    #[test]
1784    fn test_semantic_layer_nodes_from_jaffle_shop_manifest() {
1785        let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1786            .join("../../../../refs/jaffle-shop/target/manifest.json");
1787        if !manifest_path.exists() {
1788            eprintln!(
1789                "SKIP: jaffle-shop fixture not found at {manifest_path:?}; run `make fixtures` to enable this test"
1790            );
1791            return;
1792        }
1793        let graph = build_graph_from_manifest(&manifest_path).unwrap();
1794
1795        let sem_models: Vec<_> = graph
1796            .node_indices()
1797            .filter(|&i| graph[i].node_type == NodeType::SemanticModel)
1798            .collect();
1799        assert!(!sem_models.is_empty(), "Should have semantic_model nodes");
1800
1801        let metrics: Vec<_> = graph
1802            .node_indices()
1803            .filter(|&i| graph[i].node_type == NodeType::Metric)
1804            .collect();
1805        assert!(!metrics.is_empty(), "Should have metric nodes");
1806
1807        let saved_queries: Vec<_> = graph
1808            .node_indices()
1809            .filter(|&i| graph[i].node_type == NodeType::SavedQuery)
1810            .collect();
1811        assert!(!saved_queries.is_empty(), "Should have saved_query nodes");
1812
1813        // Each semantic_model should have at least one upstream edge (to a model)
1814        let sem_idx = sem_models[0];
1815        let has_upstream = graph
1816            .edges_directed(sem_idx, petgraph::Direction::Incoming)
1817            .next()
1818            .is_some();
1819        assert!(
1820            has_upstream,
1821            "semantic_model should have upstream model edge"
1822        );
1823    }
1824}