Skip to main content

sqry_lang_puppet/relations/
graph_builder.rs

1// Nested conditionals kept for readability in Puppet AST traversal
2
3//! Puppet `GraphBuilder` implementation for `CodeGraph` integration.
4//!
5//! Extracts class dependency edges from Puppet manifests:
6//! - `include myclass` → Class include relationship
7//! - `require myclass` → Class require relationship (stronger than include)
8//! - `class myclass inherits parent` → Class inheritance
9//!
10//! NOTE: `contain` statements are not supported by the tree-sitter-puppet grammar
11//! (they are parsed as bare identifiers, not as a statement type).
12
13use std::collections::HashMap;
14use std::path::Path;
15
16use sqry_core::graph::unified::edge::kind::TypeOfContext;
17use sqry_core::graph::{
18    GraphBuilder, GraphResult, Language, Span,
19    unified::{GraphBuildHelper, NodeId as UnifiedNodeId, StagingGraph},
20};
21use tree_sitter::{Node, Tree};
22
23/// `GraphBuilder` for Puppet manifests
24#[derive(Debug, Default)]
25pub struct PuppetGraphBuilder;
26
27impl PuppetGraphBuilder {
28    /// Create a new Puppet graph builder
29    #[must_use]
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35impl GraphBuilder for PuppetGraphBuilder {
36    fn build_graph(
37        &self,
38        tree: &Tree,
39        content: &[u8],
40        file: &Path,
41        staging: &mut StagingGraph,
42    ) -> GraphResult<()> {
43        // Create helper for staging graph population
44        let mut helper = GraphBuildHelper::new(staging, file, Language::Puppet);
45
46        // Create module node for this Puppet file
47        let module_id = helper.add_module("<module>", None);
48
49        // First pass: collect class and defined type definitions and create export edges
50        let mut class_ids = std::collections::HashMap::new();
51        collect_class_definitions(
52            tree.root_node(),
53            content,
54            module_id,
55            &mut helper,
56            &mut class_ids,
57        )?;
58
59        // Second pass: walk AST to find include/require/contain/inherits statements
60        walk_ast_with_helper(
61            tree.root_node(),
62            content,
63            module_id,
64            &mut helper,
65            &class_ids,
66        )?;
67
68        Ok(())
69    }
70
71    fn language(&self) -> Language {
72        Language::Puppet
73    }
74}
75
76/// First pass: collect all class and defined type definitions, create nodes and export edges
77fn collect_class_definitions(
78    node: Node<'_>,
79    content: &[u8],
80    module_id: UnifiedNodeId,
81    helper: &mut GraphBuildHelper,
82    class_ids: &mut HashMap<String, UnifiedNodeId>,
83) -> GraphResult<()> {
84    match node.kind() {
85        "class_definition" | "defined_resource_type" => {
86            // Extract class/defined type name
87            let mut cursor = node.walk();
88            for child in node.children(&mut cursor) {
89                if (child.kind() == "identifier" || child.kind() == "class_identifier")
90                    && let Ok(name) = child.utf8_text(content)
91                {
92                    let class_name = name.to_string();
93                    let span = Some(span_from_node(node));
94
95                    // Create class node
96                    let class_id = helper.add_class(&class_name, span);
97                    class_ids.insert(class_name.clone(), class_id);
98
99                    // Export this class from the module
100                    helper.add_export_edge(module_id, class_id);
101
102                    // Extract TypeOf edges from typed parameters
103                    extract_parameter_types(node, content, class_id, &class_name, helper);
104                    break;
105                }
106            }
107        }
108        _ => {}
109    }
110
111    // Recurse into children
112    let mut cursor = node.walk();
113    for child in node.children(&mut cursor) {
114        collect_class_definitions(child, content, module_id, helper, class_ids)?;
115    }
116
117    Ok(())
118}
119
120/// Check if a tree-sitter node kind represents a Puppet type annotation
121fn is_puppet_type_kind(kind: &str) -> bool {
122    matches!(
123        kind,
124        "builtin_type"
125            | "array_type"
126            | "composite_type"
127            | "attribute_type"
128            | "class_identifier"
129            | "identifier"
130    )
131}
132
133/// Extract `TypeOf` edges from typed parameters in class/define definitions.
134///
135/// Puppet parameters can optionally have type annotations:
136/// - `String $pkg = 'nginx'` → `TypeOf(myapp::pkg, String)`
137/// - `Array[String] $pkgs` → `TypeOf(myapp::pkgs, Array[String])`
138/// - `$untyped = 'x'` → no `TypeOf` edge (no type annotation)
139///
140/// Variable nodes are scope-qualified as `<class_name>::<param_name>` to prevent
141/// collisions when different classes share the same parameter name.
142fn extract_parameter_types(
143    node: Node<'_>,
144    content: &[u8],
145    class_id: UnifiedNodeId,
146    class_name: &str,
147    helper: &mut GraphBuildHelper,
148) {
149    // Find the parameter_list child of the class/define node
150    let mut cursor = node.walk();
151    for child in node.children(&mut cursor) {
152        if child.kind() == "parameter_list" {
153            let mut param_cursor = child.walk();
154            for param in child.children(&mut param_cursor) {
155                if param.kind() == "parameter" {
156                    extract_single_param_type(param, content, class_id, class_name, helper);
157                }
158            }
159        }
160    }
161}
162
163/// Extract `TypeOf` edge from a single Puppet parameter node.
164///
165/// AST structure for typed parameter: `String $pkg = 'nginx'`
166///   parameter
167///     `builtin_type`  → "String"      (type annotation)
168///     variable        → "$pkg"        (parameter name)
169///     =
170///     string          → "'nginx'"     (default value)
171///
172/// For untyped parameter: `$untyped = 'x'`
173///   parameter
174///     variable      → "$untyped"    (no type annotation)
175///     =
176///     string        → "'x'"
177///
178/// Variable nodes are scope-qualified as `<class_name>::<param_name>` to prevent
179/// collisions when different classes share the same parameter name.
180fn extract_single_param_type(
181    param: Node<'_>,
182    content: &[u8],
183    class_id: UnifiedNodeId,
184    class_name: &str,
185    helper: &mut GraphBuildHelper,
186) {
187    let mut type_text = None;
188    let mut var_name = None;
189
190    let mut cursor = param.walk();
191    for child in param.children(&mut cursor) {
192        if type_text.is_none() && is_puppet_type_kind(child.kind()) && child.kind() != "identifier"
193        {
194            // Type annotation node (builtin_type, array_type, etc.)
195            type_text = child.utf8_text(content).ok().map(ToString::to_string);
196        } else if child.kind() == "identifier" && type_text.is_none() && var_name.is_none() {
197            // An identifier before a variable could be a type name (e.g., custom class type)
198            // But only if the next sibling is a variable node
199            let maybe_type = child.utf8_text(content).ok().map(ToString::to_string);
200            // Look ahead to see if a variable follows
201            if let Some(next) = child.next_named_sibling()
202                && next.kind() == "variable"
203            {
204                type_text = maybe_type;
205            }
206        } else if child.kind() == "variable" {
207            // Extract variable name, stripping the $ prefix
208            if let Ok(text) = child.utf8_text(content) {
209                var_name = Some(text.trim_start_matches('$').to_string());
210            }
211        }
212    }
213
214    // Only create TypeOf edge if we have both type and variable name
215    if let (Some(type_str), Some(name)) = (type_text, var_name) {
216        // Scope-qualify: <class_name>::<param_name> (e.g., "myapp::pkg")
217        let qualified_name = format!("{class_name}::{name}");
218        let param_id = helper.add_variable(&qualified_name, Some(span_from_node(param)));
219        let type_id = helper.add_type(&type_str, None);
220        helper.add_typeof_edge_with_context(
221            param_id,
222            type_id,
223            Some(TypeOfContext::Parameter),
224            None,
225            Some(&qualified_name),
226        );
227        helper.add_reference_edge(param_id, type_id);
228        helper.add_contains_edge(class_id, param_id);
229    }
230}
231
232/// Walk AST to find class relationships using `GraphBuildHelper`
233fn walk_ast_with_helper(
234    node: Node<'_>,
235    content: &[u8],
236    module_id: UnifiedNodeId,
237    helper: &mut GraphBuildHelper,
238    class_ids: &HashMap<String, UnifiedNodeId>,
239) -> GraphResult<()> {
240    // Check for relationship statements and call patterns
241    // NOTE: contain_statement is NOT supported by tree-sitter-puppet grammar
242    match node.kind() {
243        "include_statement" => {
244            extract_include_edge_with_helper(node, content, module_id, helper, "include");
245        }
246        "require_statement" => {
247            extract_include_edge_with_helper(node, content, module_id, helper, "require");
248        }
249        "class_definition" => {
250            // Check for inheritance
251            extract_inheritance_edge_with_helper(node, content, helper, class_ids);
252        }
253        "resource_declaration" => {
254            // Resource declarations like `file { '/path': }`, `package { 'nginx': }`
255            extract_resource_call_with_helper(node, content, module_id, helper);
256        }
257        "function_call" => {
258            // Function calls like `template('path')`, `lookup('key')`
259            extract_function_call_with_helper(node, content, module_id, helper);
260        }
261        _ => {}
262    }
263
264    // Recurse into children
265    let mut cursor = node.walk();
266    for child in node.children(&mut cursor) {
267        walk_ast_with_helper(child, content, module_id, helper, class_ids)?;
268    }
269
270    Ok(())
271}
272
273/// Extract edge from include/require/contain statement using `GraphBuildHelper`
274///
275/// The `relation_type` parameter distinguishes between:
276/// - "include": brings class into catalog (ordering independent)
277/// - "require": brings class into catalog with ordering dependency
278/// - "contain": (future) for containment relationships if grammar support is added
279///
280/// Currently all relation types produce the same Import edge, but the parameter
281/// is preserved for future semantic differentiation (e.g., edge metadata or
282/// different edge kinds for ordering-sensitive relationships).
283fn extract_include_edge_with_helper(
284    node: Node<'_>,
285    content: &[u8],
286    module_id: UnifiedNodeId,
287    helper: &mut GraphBuildHelper,
288    _relation_type: &str,
289) {
290    let mut cursor = node.walk();
291
292    for child in node.children(&mut cursor) {
293        // Look for the class identifier
294        if (child.kind() == "identifier" || child.kind() == "class_identifier")
295            && let Ok(class_name) = child.utf8_text(content)
296        {
297            // Convert class name to file path pattern
298            let class_path = class_name.replace("::", "/");
299            let qualified_name = format!("manifests/{class_path}.pp::{class_name}");
300
301            // Add class node
302            let target_id = helper.add_class(&qualified_name, Some(span_from_node(node)));
303
304            // Add import edge
305            helper.add_import_edge(module_id, target_id);
306
307            return;
308        }
309
310        // Also check for string arguments (include 'classname')
311        if child.kind() == "string"
312            && let Ok(text) = child.utf8_text(content)
313        {
314            let class_name = text.trim_matches('\'').trim_matches('"');
315            if !class_name.is_empty() {
316                // Convert class name to file path pattern
317                let class_path = class_name.replace("::", "/");
318                let qualified_name = format!("manifests/{class_path}.pp::{class_name}");
319
320                // Add class node
321                let target_id = helper.add_class(&qualified_name, Some(span_from_node(node)));
322
323                // Add import edge
324                helper.add_import_edge(module_id, target_id);
325
326                return;
327            }
328        }
329    }
330}
331
332/// Extract inheritance edge from class definition using `GraphBuildHelper`
333fn extract_inheritance_edge_with_helper(
334    node: Node<'_>,
335    content: &[u8],
336    helper: &mut GraphBuildHelper,
337    class_ids: &HashMap<String, UnifiedNodeId>,
338) {
339    let mut cursor = node.walk();
340    let mut class_name = None;
341    let mut parent_name = None;
342
343    for child in node.children(&mut cursor) {
344        match child.kind() {
345            // The class name is a direct identifier child of class_definition
346            "identifier" | "class_identifier" => {
347                if class_name.is_none()
348                    && let Ok(text) = child.utf8_text(content)
349                {
350                    class_name = Some(text.to_string());
351                }
352            }
353            // class_inherits contains: inherits keyword + identifier for parent
354            "class_inherits" => {
355                let mut inherits_cursor = child.walk();
356                for inherits_child in child.children(&mut inherits_cursor) {
357                    if (inherits_child.kind() == "identifier"
358                        || inherits_child.kind() == "class_identifier")
359                        && let Ok(text) = inherits_child.utf8_text(content)
360                    {
361                        parent_name = Some(text.to_string());
362                    }
363                }
364            }
365            _ => {}
366        }
367    }
368
369    // If we found inheritance, create edge
370    if let (Some(class_name), Some(parent)) = (class_name, parent_name) {
371        // Use existing class node from first pass
372        let class_id = if let Some(&id) = class_ids.get(&class_name) {
373            id
374        } else {
375            // Fallback: create class node if not found (shouldn't happen)
376            helper.add_class(&class_name, Some(span_from_node(node)))
377        };
378
379        // Add parent class node
380        let parent_path = parent.replace("::", "/");
381        let parent_qualified = format!("manifests/{parent_path}.pp::{parent}");
382        let parent_id = helper.add_class(&parent_qualified, None);
383
384        // Add inheritance edge
385        helper.add_inherits_edge(class_id, parent_id);
386    }
387}
388
389/// Extract call edge from resource declaration using `GraphBuildHelper`
390///
391/// Resource declarations in Puppet are like function calls that instantiate resources:
392/// - `file { '/tmp/test': ensure => present }` → Calls edge to `file` resource type
393/// - `package { 'nginx': ensure => installed }` → Calls edge to `package` resource type
394fn extract_resource_call_with_helper(
395    node: Node<'_>,
396    content: &[u8],
397    module_id: UnifiedNodeId,
398    helper: &mut GraphBuildHelper,
399) {
400    let mut cursor = node.walk();
401
402    // First child is the resource type (identifier)
403    for child in node.children(&mut cursor) {
404        if child.kind() == "identifier"
405            && let Ok(resource_type) = child.utf8_text(content)
406        {
407            // Create a function node for the resource type
408            let callee_id =
409                helper.add_function(&format!("resource::{resource_type}"), None, false, false);
410
411            // Add call edge from module to resource type
412            helper.add_call_edge_full_with_span(
413                module_id,
414                callee_id,
415                255,
416                false,
417                vec![span_from_node(node)],
418            );
419
420            return;
421        }
422    }
423}
424
425/// Extract call edge from function call using `GraphBuildHelper`
426///
427/// Function calls in Puppet like:
428/// - `template('mymodule/template.erb')` → Calls edge to `template`
429/// - `lookup('mykey')` → Calls edge to `lookup`
430/// - `hiera('config::option')` → Calls edge to `hiera`
431fn extract_function_call_with_helper(
432    node: Node<'_>,
433    content: &[u8],
434    module_id: UnifiedNodeId,
435    helper: &mut GraphBuildHelper,
436) {
437    let mut cursor = node.walk();
438
439    // First child is the function name (identifier)
440    for child in node.children(&mut cursor) {
441        if child.kind() == "identifier"
442            && let Ok(function_name) = child.utf8_text(content)
443        {
444            // Create a function node for the function
445            let callee_id = helper.add_function(function_name, None, false, false);
446
447            // Add call edge from module to function
448            helper.add_call_edge_full_with_span(
449                module_id,
450                callee_id,
451                255,
452                false,
453                vec![span_from_node(node)],
454            );
455
456            return;
457        }
458    }
459}
460
461/// Create span from tree-sitter node
462fn span_from_node(node: Node<'_>) -> Span {
463    Span::from_bytes(node.start_byte(), node.end_byte())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use sqry_core::graph::unified::build::test_helpers::*;
470    use sqry_core::graph::unified::node::NodeKind;
471    use std::path::PathBuf;
472
473    fn parse_puppet(source: &str) -> Tree {
474        let mut parser = tree_sitter::Parser::new();
475        parser
476            .set_language(&tree_sitter_puppet::LANGUAGE.into())
477            .unwrap();
478        parser.parse(source.as_bytes(), None).unwrap()
479    }
480
481    #[test]
482    fn test_extracts_include() {
483        let source = r"
484class myclass {
485  include other_class
486}
487";
488
489        let tree = parse_puppet(source);
490        let mut staging = StagingGraph::new();
491        let builder = PuppetGraphBuilder;
492        let file = PathBuf::from("manifests/myclass.pp");
493
494        builder
495            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
496            .unwrap();
497
498        let import_edges = collect_import_edges(&staging);
499        assert_eq!(import_edges.len(), 1, "Should extract one include edge");
500
501        // Verify the class definition was created
502        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
503
504        // Verify the included class node was created
505        assert_has_node(&staging, "other_class");
506    }
507
508    #[test]
509    fn test_extracts_require() {
510        let source = r"
511class myclass {
512  require base_class
513}
514";
515
516        let tree = parse_puppet(source);
517        let mut staging = StagingGraph::new();
518        let builder = PuppetGraphBuilder;
519        let file = PathBuf::from("manifests/myclass.pp");
520
521        builder
522            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
523            .unwrap();
524
525        let import_edges = collect_import_edges(&staging);
526        assert_eq!(import_edges.len(), 1, "Should extract one require edge");
527
528        // Verify the class definition was created
529        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
530
531        // Verify the required class node was created
532        assert_has_node(&staging, "base_class");
533    }
534
535    #[test]
536    fn test_extracts_string_class_name() {
537        let source = r"
538include 'mymodule::myclass'
539";
540
541        let tree = parse_puppet(source);
542        let mut staging = StagingGraph::new();
543        let builder = PuppetGraphBuilder;
544        let file = PathBuf::from("manifests/site.pp");
545
546        builder
547            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
548            .unwrap();
549
550        let import_edges = collect_import_edges(&staging);
551        assert!(
552            !import_edges.is_empty(),
553            "Should extract include edge with string"
554        );
555
556        // Verify the included class node was created
557        assert_has_node(&staging, "mymodule::myclass");
558    }
559
560    #[test]
561    fn test_multiple_includes() {
562        let source = r"
563class myclass {
564  include base
565  include networking
566  require security
567}
568";
569
570        let tree = parse_puppet(source);
571        let mut staging = StagingGraph::new();
572        let builder = PuppetGraphBuilder;
573        let file = PathBuf::from("manifests/myclass.pp");
574
575        builder
576            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
577            .unwrap();
578
579        let import_edges = collect_import_edges(&staging);
580        assert_eq!(import_edges.len(), 3, "Should extract 3 import edges");
581
582        // Verify the class definition was created
583        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
584
585        // Verify all included/required classes were created
586        assert_has_node(&staging, "base");
587        assert_has_node(&staging, "networking");
588        assert_has_node(&staging, "security");
589    }
590
591    #[test]
592    fn test_qualified_class_name() {
593        let source = r"
594include mymodule::submodule::myclass
595";
596
597        let tree = parse_puppet(source);
598        let mut staging = StagingGraph::new();
599        let builder = PuppetGraphBuilder;
600        let file = PathBuf::from("manifests/site.pp");
601
602        builder
603            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
604            .unwrap();
605
606        let import_edges = collect_import_edges(&staging);
607        assert_eq!(import_edges.len(), 1, "Should extract one import edge");
608
609        // Verify the qualified class name was captured
610        assert_has_node(&staging, "mymodule");
611    }
612
613    #[test]
614    fn test_contain_not_supported_by_grammar() {
615        // NOTE: tree-sitter-puppet grammar does not have contain_statement node type.
616        // `contain contained_class` is parsed as bare identifiers, not as a statement.
617        // This test documents the grammar limitation.
618        let source = r"
619class myclass {
620  contain contained_class
621}
622";
623
624        let tree = parse_puppet(source);
625        let mut staging = StagingGraph::new();
626        let builder = PuppetGraphBuilder;
627        let file = PathBuf::from("manifests/myclass.pp");
628
629        builder
630            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
631            .unwrap();
632
633        let import_edges = collect_import_edges(&staging);
634        // Grammar doesn't recognize contain as a statement type, so no edges should be created
635        // Only the class definition and module should exist
636        assert_eq!(import_edges.len(), 0, "Contain not supported by grammar");
637
638        // Verify the class definition was still created
639        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
640    }
641
642    #[test]
643    fn test_extracts_class_inheritance() {
644        let source = r"
645class myclass inherits parent_class {
646  # class body
647}
648";
649
650        let tree = parse_puppet(source);
651        let mut staging = StagingGraph::new();
652        let builder = PuppetGraphBuilder;
653        let file = PathBuf::from("manifests/myclass.pp");
654
655        builder
656            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
657            .unwrap();
658
659        let inherits_edges = collect_inherits_edges(&staging);
660        assert_eq!(inherits_edges.len(), 1, "Should extract one inherits edge");
661
662        // Verify both class nodes were created
663        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
664        assert_has_node(&staging, "parent_class");
665
666        // Verify the inheritance edge exists (parent is qualified)
667        assert_has_inherits_edge(
668            &staging,
669            "myclass",
670            "manifests/parent_class.pp::parent_class",
671        );
672    }
673
674    #[test]
675    fn test_class_without_inheritance() {
676        let source = r"
677class myclass {
678  # class body without inheritance
679}
680";
681
682        let tree = parse_puppet(source);
683        let mut staging = StagingGraph::new();
684        let builder = PuppetGraphBuilder;
685        let file = PathBuf::from("manifests/myclass.pp");
686
687        builder
688            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
689            .unwrap();
690
691        let inherits_edges = collect_inherits_edges(&staging);
692        assert_eq!(inherits_edges.len(), 0, "Should have no inherits edges");
693
694        // Verify the class definition was created
695        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
696    }
697
698    #[test]
699    fn test_empty_file() {
700        let source = "";
701
702        let tree = parse_puppet(source);
703        let mut staging = StagingGraph::new();
704        let builder = PuppetGraphBuilder;
705        let file = PathBuf::from("manifests/empty.pp");
706
707        builder
708            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
709            .unwrap();
710
711        // Should only have the module node for the file itself
712        assert_eq!(
713            count_nodes_by_kind(&staging, NodeKind::Module),
714            1,
715            "Empty file should have exactly one module node"
716        );
717
718        // No other nodes should exist
719        assert_eq!(
720            count_nodes_by_kind(&staging, NodeKind::Class),
721            0,
722            "Empty file should have no class nodes"
723        );
724    }
725
726    #[test]
727    fn test_mixed_statements() {
728        // Note: contain is not supported by the grammar, so we only test include/require
729        let source = r"
730class myclass inherits base_class {
731  include helper
732  require dependency
733}
734";
735
736        let tree = parse_puppet(source);
737        let mut staging = StagingGraph::new();
738        let builder = PuppetGraphBuilder;
739        let file = PathBuf::from("manifests/myclass.pp");
740
741        builder
742            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
743            .unwrap();
744
745        // Should have inheritance edge
746        let inherits_edges = collect_inherits_edges(&staging);
747        assert_eq!(inherits_edges.len(), 1, "Should have one inherits edge");
748
749        // Should have import edges for include and require
750        let import_edges = collect_import_edges(&staging);
751        assert_eq!(
752            import_edges.len(),
753            2,
754            "Should have two import edges (include + require)"
755        );
756
757        // Verify all nodes were created
758        assert_has_node_with_kind(&staging, "myclass", NodeKind::Class);
759        assert_has_node(&staging, "base_class");
760        assert_has_node(&staging, "helper");
761        assert_has_node(&staging, "dependency");
762
763        // Verify the inheritance edge (parent is qualified)
764        assert_has_inherits_edge(&staging, "myclass", "manifests/base_class.pp::base_class");
765    }
766
767    #[test]
768    fn test_nested_class_include() {
769        let source = r"
770class outer {
771  class { 'inner':
772    # nested resource-style class
773  }
774  include nested_include
775}
776";
777
778        let tree = parse_puppet(source);
779        let mut staging = StagingGraph::new();
780        let builder = PuppetGraphBuilder;
781        let file = PathBuf::from("manifests/outer.pp");
782
783        builder
784            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
785            .unwrap();
786
787        let import_edges = collect_import_edges(&staging);
788        assert!(
789            !import_edges.is_empty(),
790            "Should extract include edge from nested class"
791        );
792
793        // Verify the outer class and included class nodes
794        assert_has_node_with_kind(&staging, "outer", NodeKind::Class);
795        assert_has_node(&staging, "nested_include");
796    }
797}
798
799// Active tests for Unified Graph (Wave 8)
800#[cfg(test)]
801mod active_tests {
802    use super::*;
803    use sqry_core::graph::unified::build::StagingOp;
804    use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
805    use std::path::PathBuf;
806
807    fn parse_puppet(source: &str) -> Tree {
808        let mut parser = tree_sitter::Parser::new();
809        parser
810            .set_language(&tree_sitter_puppet::LANGUAGE.into())
811            .unwrap();
812        parser.parse(source.as_bytes(), None).unwrap()
813    }
814
815    /// Helper to extract Import edges from staging operations
816    fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
817        staging
818            .operations()
819            .iter()
820            .filter_map(|op| {
821                if let StagingOp::AddEdge { kind, .. } = op
822                    && matches!(kind, UnifiedEdgeKind::Imports { .. })
823                {
824                    return Some(kind);
825                }
826                None
827            })
828            .collect()
829    }
830
831    /// Helper to extract Call edges from staging operations
832    fn extract_call_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
833        staging
834            .operations()
835            .iter()
836            .filter_map(|op| {
837                if let StagingOp::AddEdge { kind, .. } = op
838                    && matches!(kind, UnifiedEdgeKind::Calls { .. })
839                {
840                    return Some(kind);
841                }
842                None
843            })
844            .collect()
845    }
846
847    /// Helper to extract Inherits edges from staging operations
848    fn extract_inherits_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
849        staging
850            .operations()
851            .iter()
852            .filter_map(|op| {
853                if let StagingOp::AddEdge { kind, .. } = op
854                    && matches!(kind, UnifiedEdgeKind::Inherits)
855                {
856                    return Some(kind);
857                }
858                None
859            })
860            .collect()
861    }
862
863    #[test]
864    fn test_puppet_graph_builder_language() {
865        let builder = PuppetGraphBuilder;
866        assert_eq!(builder.language(), Language::Puppet);
867    }
868
869    #[test]
870    fn test_extracts_resource_declaration_calls() {
871        let source = r"
872class myapp {
873  package { 'nginx':
874    ensure => installed,
875  }
876
877  service { 'nginx':
878    ensure => running,
879  }
880
881  file { '/etc/nginx/nginx.conf':
882    ensure => present,
883  }
884}
885";
886
887        let tree = parse_puppet(source);
888        let mut staging = StagingGraph::new();
889        let builder = PuppetGraphBuilder;
890        let file = PathBuf::from("manifests/myapp.pp");
891
892        builder
893            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
894            .expect("build_graph should succeed");
895
896        let call_edges = extract_call_edges(&staging);
897        assert_eq!(
898            call_edges.len(),
899            3,
900            "Should extract three call edges for resource declarations (package, service, file)"
901        );
902    }
903
904    #[test]
905    fn test_extracts_function_calls() {
906        let source = r#"
907define myapp::config($port = 80) {
908  file { "/etc/myapp/${name}.conf":
909    content => template('myapp/config.erb'),
910  }
911}
912"#;
913
914        let tree = parse_puppet(source);
915        let mut staging = StagingGraph::new();
916        let builder = PuppetGraphBuilder;
917        let file = PathBuf::from("manifests/myapp/config.pp");
918
919        builder
920            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
921            .expect("build_graph should succeed");
922
923        let call_edges = extract_call_edges(&staging);
924        // Should have at least: file resource + template function
925        assert!(
926            call_edges.len() >= 2,
927            "Should extract call edges for file resource and template() function"
928        );
929    }
930
931    #[test]
932    fn test_extracts_include_import_edge() {
933        let source = r"
934class webserver {
935  include myapp
936}
937";
938
939        let tree = parse_puppet(source);
940        let mut staging = StagingGraph::new();
941        let builder = PuppetGraphBuilder;
942        let file = PathBuf::from("manifests/webserver.pp");
943
944        builder
945            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
946            .expect("build_graph should succeed");
947
948        let import_edges = extract_import_edges(&staging);
949        assert_eq!(
950            import_edges.len(),
951            1,
952            "Should extract one import edge for include statement"
953        );
954    }
955
956    #[test]
957    fn test_extracts_require_import_edge() {
958        let source = r"
959class webserver {
960  require myapp::prereqs
961}
962";
963
964        let tree = parse_puppet(source);
965        let mut staging = StagingGraph::new();
966        let builder = PuppetGraphBuilder;
967        let file = PathBuf::from("manifests/webserver.pp");
968
969        builder
970            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
971            .expect("build_graph should succeed");
972
973        let import_edges = extract_import_edges(&staging);
974        assert_eq!(
975            import_edges.len(),
976            1,
977            "Should extract one import edge for require statement"
978        );
979    }
980
981    #[test]
982    fn test_extracts_inheritance_edge() {
983        let source = r"
984class child inherits parent {
985  # child class body
986}
987";
988
989        let tree = parse_puppet(source);
990        let mut staging = StagingGraph::new();
991        let builder = PuppetGraphBuilder;
992        let file = PathBuf::from("manifests/child.pp");
993
994        builder
995            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
996            .expect("build_graph should succeed");
997
998        let inherits_edges = extract_inherits_edges(&staging);
999        assert_eq!(
1000            inherits_edges.len(),
1001            1,
1002            "Should extract one inherits edge for class inheritance"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_empty_file_no_edges() {
1008        let source = "";
1009
1010        let tree = parse_puppet(source);
1011        let mut staging = StagingGraph::new();
1012        let builder = PuppetGraphBuilder;
1013        let file = PathBuf::from("manifests/empty.pp");
1014
1015        builder
1016            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1017            .expect("build_graph should succeed");
1018
1019        let call_edges = extract_call_edges(&staging);
1020        assert!(
1021            call_edges.is_empty(),
1022            "Empty file should have no call edges"
1023        );
1024
1025        let import_edges = extract_import_edges(&staging);
1026        assert!(
1027            import_edges.is_empty(),
1028            "Empty file should have no import edges"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_mixed_statements() {
1034        let source = r"
1035class myapp inherits base {
1036  include helper
1037  require prereqs
1038
1039  package { 'app':
1040    ensure => installed,
1041  }
1042
1043  $config = lookup('app::config')
1044}
1045";
1046
1047        let tree = parse_puppet(source);
1048        let mut staging = StagingGraph::new();
1049        let builder = PuppetGraphBuilder;
1050        let file = PathBuf::from("manifests/myapp.pp");
1051
1052        builder
1053            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1054            .expect("build_graph should succeed");
1055
1056        // Should have import edges for include and require
1057        let import_edges = extract_import_edges(&staging);
1058        assert_eq!(
1059            import_edges.len(),
1060            2,
1061            "Should extract two import edges (include + require)"
1062        );
1063
1064        // Should have inherits edge
1065        let inherits_edges = extract_inherits_edges(&staging);
1066        assert_eq!(inherits_edges.len(), 1, "Should have one inherits edge");
1067
1068        // Should have call edges for package resource and lookup function
1069        let call_edges = extract_call_edges(&staging);
1070        assert!(
1071            call_edges.len() >= 2,
1072            "Should have at least 2 call edges (package resource + lookup function)"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_node_definition_with_resources() {
1078        let source = r"
1079node 'web01.example.com' {
1080  package { 'nginx':
1081    ensure => installed,
1082  }
1083}
1084";
1085
1086        let tree = parse_puppet(source);
1087        let mut staging = StagingGraph::new();
1088        let builder = PuppetGraphBuilder;
1089        let file = PathBuf::from("manifests/nodes.pp");
1090
1091        builder
1092            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1093            .expect("build_graph should succeed");
1094
1095        let call_edges = extract_call_edges(&staging);
1096        assert_eq!(
1097            call_edges.len(),
1098            1,
1099            "Should extract one call edge for package resource"
1100        );
1101    }
1102
1103    /// Helper to extract `TypeOf` edges from staging operations
1104    fn extract_typeof_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
1105        staging
1106            .operations()
1107            .iter()
1108            .filter_map(|op| {
1109                if let StagingOp::AddEdge { kind, .. } = op
1110                    && matches!(kind, UnifiedEdgeKind::TypeOf { .. })
1111                {
1112                    return Some(kind);
1113                }
1114                None
1115            })
1116            .collect()
1117    }
1118
1119    #[test]
1120    fn test_class_typed_params_create_typeof_edges() {
1121        let source = r"
1122class myapp (
1123  String $pkg,
1124  Integer $port = 80,
1125) {
1126  # class body
1127}
1128";
1129
1130        let tree = parse_puppet(source);
1131        let mut staging = StagingGraph::new();
1132        let builder = PuppetGraphBuilder;
1133        let file = PathBuf::from("manifests/myapp.pp");
1134
1135        builder
1136            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1137            .expect("build_graph should succeed");
1138
1139        let typeof_edges = extract_typeof_edges(&staging);
1140        assert_eq!(
1141            typeof_edges.len(),
1142            2,
1143            "Class with 2 typed parameters should create 2 TypeOf edges"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_define_typed_params_create_typeof_edges() {
1149        let source = r"
1150define myapp::config (
1151  String $name,
1152  Boolean $ssl = false,
1153) {
1154  # define body
1155}
1156";
1157
1158        let tree = parse_puppet(source);
1159        let mut staging = StagingGraph::new();
1160        let builder = PuppetGraphBuilder;
1161        let file = PathBuf::from("manifests/myapp/config.pp");
1162
1163        builder
1164            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1165            .expect("build_graph should succeed");
1166
1167        let typeof_edges = extract_typeof_edges(&staging);
1168        assert_eq!(
1169            typeof_edges.len(),
1170            2,
1171            "Define with 2 typed parameters should create 2 TypeOf edges"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_class_untyped_params_no_typeof_edges() {
1177        let source = r"
1178class myapp (
1179  $pkg = 'nginx',
1180  $port = 80,
1181) {
1182  # class body
1183}
1184";
1185
1186        let tree = parse_puppet(source);
1187        let mut staging = StagingGraph::new();
1188        let builder = PuppetGraphBuilder;
1189        let file = PathBuf::from("manifests/myapp.pp");
1190
1191        builder
1192            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1193            .expect("build_graph should succeed");
1194
1195        let typeof_edges = extract_typeof_edges(&staging);
1196        assert!(
1197            typeof_edges.is_empty(),
1198            "Class with only untyped parameters should have no TypeOf edges"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_complex_puppet_types_create_typeof_edges() {
1204        let source = r"
1205class myapp (
1206  Array[String] $pkgs,
1207  Hash $config = {},
1208) {
1209  # class body
1210}
1211";
1212
1213        let tree = parse_puppet(source);
1214        let mut staging = StagingGraph::new();
1215        let builder = PuppetGraphBuilder;
1216        let file = PathBuf::from("manifests/myapp.pp");
1217
1218        builder
1219            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1220            .expect("build_graph should succeed");
1221
1222        let typeof_edges = extract_typeof_edges(&staging);
1223        assert_eq!(
1224            typeof_edges.len(),
1225            2,
1226            "Class with 2 complex typed parameters should create 2 TypeOf edges"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_mixed_typed_untyped_params() {
1232        let source = r"
1233class myapp (
1234  String $name,
1235  $untyped_param,
1236  Integer $port = 80,
1237) {
1238  # class body
1239}
1240";
1241
1242        let tree = parse_puppet(source);
1243        let mut staging = StagingGraph::new();
1244        let builder = PuppetGraphBuilder;
1245        let file = PathBuf::from("manifests/myapp.pp");
1246
1247        builder
1248            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1249            .expect("build_graph should succeed");
1250
1251        let typeof_edges = extract_typeof_edges(&staging);
1252        assert_eq!(
1253            typeof_edges.len(),
1254            2,
1255            "Class with 2 typed + 1 untyped should create 2 TypeOf edges"
1256        );
1257    }
1258
1259    #[test]
1260    fn test_class_without_params_no_typeof_edges() {
1261        let source = r"
1262class myapp {
1263  package { 'nginx':
1264    ensure => installed,
1265  }
1266}
1267";
1268
1269        let tree = parse_puppet(source);
1270        let mut staging = StagingGraph::new();
1271        let builder = PuppetGraphBuilder;
1272        let file = PathBuf::from("manifests/myapp.pp");
1273
1274        builder
1275            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1276            .expect("build_graph should succeed");
1277
1278        let typeof_edges = extract_typeof_edges(&staging);
1279        assert!(
1280            typeof_edges.is_empty(),
1281            "Class without parameters should have no TypeOf edges"
1282        );
1283    }
1284
1285    #[test]
1286    #[allow(clippy::items_after_statements)] // Items near usage for clarity
1287    fn test_same_param_name_different_classes_distinct_nodes() {
1288        // Two classes with same parameter name should produce distinct Variable nodes
1289        // thanks to scope-qualified naming: foo::name vs bar::name
1290        let source = r"
1291class foo (
1292  String $name = 'a',
1293) { }
1294
1295class bar (
1296  Integer $name = 1,
1297) { }
1298";
1299
1300        let tree = parse_puppet(source);
1301        let mut staging = StagingGraph::new();
1302        let builder = PuppetGraphBuilder;
1303        let file = PathBuf::from("manifests/classes.pp");
1304
1305        builder
1306            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1307            .expect("build_graph should succeed");
1308
1309        let typeof_edges = extract_typeof_edges(&staging);
1310        assert_eq!(
1311            typeof_edges.len(),
1312            2,
1313            "Two classes with typed params should create 2 TypeOf edges"
1314        );
1315
1316        // Verify distinct scope-qualified variable nodes
1317        use sqry_core::graph::unified::build::test_helpers::assert_has_node_with_kind;
1318        use sqry_core::graph::unified::node::NodeKind;
1319        assert_has_node_with_kind(&staging, "foo::name", NodeKind::Variable);
1320        assert_has_node_with_kind(&staging, "bar::name", NodeKind::Variable);
1321
1322        // Verify distinct type nodes
1323        assert_has_node_with_kind(&staging, "String", NodeKind::Type);
1324        assert_has_node_with_kind(&staging, "Integer", NodeKind::Type);
1325    }
1326
1327    #[test]
1328    #[allow(clippy::items_after_statements)] // Items near usage for clarity
1329    fn test_custom_namespaced_type_extracts_correctly() {
1330        // Validates that class_identifier types (e.g., Stdlib::Absolutepath)
1331        // are correctly extracted as type text
1332        let source = r"
1333class myapp (
1334  String $name,
1335  Stdlib::Absolutepath $config_dir = '/etc/myapp',
1336) {
1337  # class body
1338}
1339";
1340
1341        let tree = parse_puppet(source);
1342        let mut staging = StagingGraph::new();
1343        let builder = PuppetGraphBuilder;
1344        let file = PathBuf::from("manifests/myapp.pp");
1345
1346        builder
1347            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1348            .expect("build_graph should succeed");
1349
1350        let typeof_edges = extract_typeof_edges(&staging);
1351        assert_eq!(
1352            typeof_edges.len(),
1353            2,
1354            "Should have 2 TypeOf edges (String + Stdlib::Absolutepath)"
1355        );
1356
1357        // Verify the namespaced type node was created with correct text
1358        use sqry_core::graph::unified::build::test_helpers::assert_has_node_with_kind;
1359        use sqry_core::graph::unified::node::NodeKind;
1360        assert_has_node_with_kind(&staging, "Stdlib::Absolutepath", NodeKind::Type);
1361        assert_has_node_with_kind(&staging, "String", NodeKind::Type);
1362        assert_has_node_with_kind(&staging, "myapp::config_dir", NodeKind::Variable);
1363    }
1364}
1365// Collapsible nested conditionals kept for readability with early-exit patterns