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