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