Skip to main content

sqry_classpath/graph/
emitter.rs

1//! Emit classpath nodes and edges into the sqry unified graph.
2//!
3//! This module takes a [`ClasspathIndex`] (produced by bytecode parsing) and
4//! emits synthetic graph nodes and edges into a [`StagingGraph`]. Each JAR gets
5//! a synthetic [`FileId`] via [`FileRegistry::register_external()`], and each
6//! class/method/field becomes a graph node with zero-span metadata (since the
7//! data comes from bytecode, not source).
8//!
9//! ## Emission pipeline
10//!
11//! 1. **File registration** - For each unique JAR path, register a synthetic
12//!    `FileEntry` with path convention `{jar_path}!/{fqn}.class`.
13//! 2. **Node emission** - For each `ClassStub`, emit nodes for the class itself,
14//!    its methods, fields, annotations, type parameters, enum constants, lambda
15//!    targets, and module declarations.
16//! 3. **Structural edges** - Class→Method (`Defines`), Class→Field (`Defines`),
17//!    Class→InnerClass (`Contains`).
18//! 4. **Metadata attachment** - `ClasspathNodeMetadata` on all emitted nodes
19//!    (class, method, field, enum constant, type parameter, lambda target,
20//!    module).
21//!
22//! After emission, the returned `FQN→NodeId` mapping is used by
23//! [`register_classpath_exports`] and [`create_classpath_edges`] for `ExportMap`
24//! registration and cross-reference edge creation.
25
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28
29use sqry_core::graph::node::Language;
30use sqry_core::graph::unified::build::ExportMap;
31use sqry_core::graph::unified::build::StagingGraph;
32use sqry_core::graph::unified::edge::EdgeKind;
33use sqry_core::graph::unified::node::NodeKind;
34use sqry_core::graph::unified::storage::metadata::{
35    ClasspathNodeMetadata, NodeMetadata, NodeMetadataStore,
36};
37use sqry_core::graph::unified::storage::registry::FileRegistry;
38use sqry_core::graph::unified::storage::{NodeEntry, StringInterner};
39use sqry_core::graph::unified::{FileId, NodeId, StringId};
40
41use crate::stub::index::ClasspathIndex;
42use crate::stub::model::{ClassKind, ClassStub};
43use crate::{ClasspathError, ClasspathResult};
44
45use super::provenance::ClasspathProvenance;
46
47// ---------------------------------------------------------------------------
48// String interning helper
49// ---------------------------------------------------------------------------
50
51/// Helper to intern strings via `StringInterner` and track the resulting IDs.
52///
53/// Unlike `GraphBuildHelper` which uses staging-local IDs, the classpath emitter
54/// interns directly into the `StringInterner` because classpath emission happens
55/// as a discrete phase before the per-file build pipeline. The resulting global
56/// `StringId`s are used directly in `NodeEntry` fields.
57struct InternHelper<'a> {
58    interner: &'a mut StringInterner,
59    cache: HashMap<String, StringId>,
60}
61
62impl<'a> InternHelper<'a> {
63    fn new(interner: &'a mut StringInterner) -> Self {
64        Self {
65            interner,
66            cache: HashMap::new(),
67        }
68    }
69
70    /// Intern a string, returning a global `StringId`.
71    ///
72    /// Caches results to avoid repeated lookups for common strings like
73    /// visibility modifiers.
74    fn intern(&mut self, s: &str) -> ClasspathResult<StringId> {
75        if let Some(&id) = self.cache.get(s) {
76            return Ok(id);
77        }
78        let id = self.interner.intern(s).map_err(|e| {
79            ClasspathError::EmissionError(format!("string intern failed for '{s}': {e}"))
80        })?;
81        self.cache.insert(s.to_owned(), id);
82        Ok(id)
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Visibility mapping
88// ---------------------------------------------------------------------------
89
90/// Map JVM access flags to a visibility string for the graph.
91#[allow(clippy::trivially_copy_pass_by_ref)] // API consistency with other methods
92fn access_to_visibility(access: &crate::stub::model::AccessFlags) -> &'static str {
93    if access.is_public() {
94        "public"
95    } else if access.is_protected() {
96        "protected"
97    } else if access.is_private() {
98        "private"
99    } else {
100        "package"
101    }
102}
103
104/// Map `ClassKind` to the corresponding `NodeKind`.
105fn class_kind_to_node_kind(kind: ClassKind) -> NodeKind {
106    match kind {
107        ClassKind::Class | ClassKind::Record => NodeKind::Class,
108        ClassKind::Interface => NodeKind::Interface,
109        ClassKind::Enum => NodeKind::Enum,
110        ClassKind::Annotation => NodeKind::Annotation,
111        ClassKind::Module => NodeKind::JavaModule,
112    }
113}
114
115// ---------------------------------------------------------------------------
116// File ID management
117// ---------------------------------------------------------------------------
118
119/// Register a synthetic external file for a class within a JAR.
120///
121/// Path convention: `{jar_path}!/{fqn}.class` (similar to Java URL conventions
122/// for JAR entries like `jar:file:///path.jar!/com/example/Foo.class`).
123fn register_synthetic_file(
124    jar_path: &Path,
125    fqn: &str,
126    file_registry: &mut FileRegistry,
127) -> ClasspathResult<FileId> {
128    let class_path_str = fqn.replace('.', "/");
129    let synthetic_path = format!("{}!/{class_path_str}.class", jar_path.display());
130    let path = PathBuf::from(&synthetic_path);
131    file_registry
132        .register_external(&path, Some(Language::Java))
133        .map_err(|e| {
134            ClasspathError::EmissionError(format!(
135                "failed to register synthetic file for {fqn}: {e}"
136            ))
137        })
138}
139
140// ---------------------------------------------------------------------------
141// Node emission
142// ---------------------------------------------------------------------------
143
144/// Emit all classpath nodes and edges into a staging graph.
145///
146/// Returns the mapping of FQN to `NodeId` for `ExportMap` registration and
147/// cross-reference edge creation.
148///
149/// # Arguments
150///
151/// * `index` - The merged classpath index containing all parsed class stubs.
152/// * `staging` - The staging graph to emit nodes and edges into.
153/// * `file_registry` - File registry for synthetic file registration.
154/// * `interner` - String interner for name/qualifier interning.
155/// * `metadata_store` - Metadata store for classpath provenance attachment.
156/// * `provenance` - Provenance information for each JAR.
157///
158/// # Errors
159///
160/// Returns `ClasspathError::EmissionError` if string interning or file
161/// registration fails.
162#[allow(clippy::too_many_lines)]
163pub fn emit_classpath_nodes(
164    index: &ClasspathIndex,
165    staging: &mut StagingGraph,
166    file_registry: &mut FileRegistry,
167    interner: &mut StringInterner,
168    metadata_store: &mut NodeMetadataStore,
169    provenance: &[ClasspathProvenance],
170) -> ClasspathResult<EmissionResult> {
171    let mut helper = InternHelper::new(interner);
172    let mut fqn_to_node: HashMap<String, NodeId> = HashMap::with_capacity(index.classes.len());
173    let mut file_id_map: HashMap<String, FileId> = HashMap::new();
174
175    // Build a lookup from JAR path to provenance for O(1) access.
176    let prov_map: HashMap<&Path, &ClasspathProvenance> = provenance
177        .iter()
178        .map(|p| (p.jar_path.as_path(), p))
179        .collect();
180
181    for stub in &index.classes {
182        emit_class_stub(
183            stub,
184            staging,
185            file_registry,
186            &mut helper,
187            metadata_store,
188            &prov_map,
189            &mut fqn_to_node,
190            &mut file_id_map,
191        )?;
192    }
193
194    Ok(EmissionResult {
195        fqn_to_node,
196        file_id_map,
197    })
198}
199
200/// Result of classpath node emission.
201#[derive(Debug)]
202pub struct EmissionResult {
203    /// Mapping from fully qualified class name to its graph `NodeId`.
204    pub fqn_to_node: HashMap<String, NodeId>,
205    /// Mapping from fully qualified class name to its synthetic `FileId`.
206    pub file_id_map: HashMap<String, FileId>,
207}
208
209/// Emit a single class stub and all its members into the staging graph.
210#[allow(clippy::too_many_lines)]
211#[allow(clippy::similar_names)] // Domain variable naming is intentional
212fn emit_class_stub(
213    stub: &ClassStub,
214    staging: &mut StagingGraph,
215    file_registry: &mut FileRegistry,
216    helper: &mut InternHelper<'_>,
217    metadata_store: &mut NodeMetadataStore,
218    prov_map: &HashMap<&Path, &ClasspathProvenance>,
219    fqn_to_node: &mut HashMap<String, NodeId>,
220    file_id_map: &mut HashMap<String, FileId>,
221) -> ClasspathResult<()> {
222    // Determine JAR path from the stub's source_jar (set during scanning),
223    // falling back to the first provenance entry or a synthetic path.
224    let jar_path = if let Some(ref src_jar) = stub.source_jar {
225        PathBuf::from(src_jar)
226    } else if let Some((&path, _)) = prov_map.iter().next() {
227        path.to_path_buf()
228    } else {
229        PathBuf::from(format!("<classpath>/{}.class", stub.fqn.replace('.', "/")))
230    };
231    let file_id = register_synthetic_file(&jar_path, &stub.fqn, file_registry)?;
232    file_id_map.insert(stub.fqn.clone(), file_id);
233
234    // --- Class node ---
235    let node_kind = class_kind_to_node_kind(stub.kind);
236    let name_id = helper.intern(&stub.name)?;
237    let qname_id = helper.intern(&stub.fqn)?;
238    let vis_id = helper.intern(access_to_visibility(&stub.access))?;
239
240    let class_entry = NodeEntry::new(node_kind, name_id, file_id)
241        .with_qualified_name(qname_id)
242        .with_visibility(vis_id)
243        .with_static(stub.access.is_static())
244        .with_unsafe(false);
245
246    let class_node_id = staging.add_node(class_entry);
247    fqn_to_node.insert(stub.fqn.clone(), class_node_id);
248
249    // --- Build ClasspathNodeMetadata (shared by class and all members) ---
250    let prov = find_provenance_for_jar(&jar_path, prov_map);
251    let cp_meta = ClasspathNodeMetadata {
252        coordinates: prov.and_then(|p| p.coordinates.clone()),
253        jar_path: jar_path.display().to_string(),
254        fqn: stub.fqn.clone(),
255        is_direct_dependency: prov.is_some_and(|p| p.is_direct),
256    };
257    metadata_store.insert_metadata(class_node_id, NodeMetadata::Classpath(cp_meta.clone()));
258
259    // --- Methods ---
260    for method in &stub.methods {
261        let method_name_id = helper.intern(&method.name)?;
262        // Include the descriptor in the key to disambiguate overloaded methods.
263        // JVM methods are uniquely identified by (name, descriptor).
264        let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
265        #[allow(clippy::similar_names)] // Domain terminology: source/target node pairs
266        let method_qname_id = helper.intern(&method_fqn)?;
267        let method_vis_id = helper.intern(access_to_visibility(&method.access))?;
268
269        let method_entry = NodeEntry::new(NodeKind::Method, method_name_id, file_id)
270            .with_qualified_name(method_qname_id)
271            .with_visibility(method_vis_id)
272            .with_static(method.access.is_static());
273
274        let method_node_id = staging.add_node(method_entry);
275        fqn_to_node.insert(method_fqn, method_node_id);
276        metadata_store.insert_metadata(method_node_id, NodeMetadata::Classpath(cp_meta.clone()));
277
278        // Class → Method: Defines
279        staging.add_edge(class_node_id, method_node_id, EdgeKind::Defines, file_id);
280    }
281
282    // --- Fields ---
283    for field in &stub.fields {
284        let field_name_id = helper.intern(&field.name)?;
285        let field_fqn = format!("{}.{}", stub.fqn, field.name);
286        let field_qname_id = helper.intern(&field_fqn)?;
287        let field_vis_id = helper.intern(access_to_visibility(&field.access))?;
288
289        let field_entry = NodeEntry::new(NodeKind::Property, field_name_id, file_id)
290            .with_qualified_name(field_qname_id)
291            .with_visibility(field_vis_id)
292            .with_static(field.access.is_static());
293
294        let field_node_id = staging.add_node(field_entry);
295        fqn_to_node.insert(field_fqn, field_node_id);
296        metadata_store.insert_metadata(field_node_id, NodeMetadata::Classpath(cp_meta.clone()));
297
298        // Class → Field: Defines
299        staging.add_edge(class_node_id, field_node_id, EdgeKind::Defines, file_id);
300    }
301
302    // --- Enum constants ---
303    for constant_name in &stub.enum_constants {
304        let const_name_id = helper.intern(constant_name)?;
305        let const_fqn = format!("{}.{constant_name}", stub.fqn);
306        let const_qname_id = helper.intern(&const_fqn)?;
307
308        let const_entry = NodeEntry::new(NodeKind::EnumConstant, const_name_id, file_id)
309            .with_qualified_name(const_qname_id)
310            .with_visibility(helper.intern("public")?);
311
312        let const_node_id = staging.add_node(const_entry);
313        fqn_to_node.insert(const_fqn, const_node_id);
314        metadata_store.insert_metadata(const_node_id, NodeMetadata::Classpath(cp_meta.clone()));
315
316        // Class → EnumConstant: Defines
317        staging.add_edge(class_node_id, const_node_id, EdgeKind::Defines, file_id);
318    }
319
320    // --- Type parameters ---
321    if let Some(ref gen_sig) = stub.generic_signature {
322        for tp in &gen_sig.type_parameters {
323            let tp_name_id = helper.intern(&tp.name)?;
324            let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
325            let tp_qname_id = helper.intern(&tp_fqn)?;
326
327            let tp_entry = NodeEntry::new(NodeKind::TypeParameter, tp_name_id, file_id)
328                .with_qualified_name(tp_qname_id);
329
330            let tp_node_id = staging.add_node(tp_entry);
331            fqn_to_node.insert(tp_fqn, tp_node_id);
332            metadata_store.insert_metadata(tp_node_id, NodeMetadata::Classpath(cp_meta.clone()));
333
334            // Class → TypeParameter: TypeArgument
335            staging.add_edge(class_node_id, tp_node_id, EdgeKind::TypeArgument, file_id);
336        }
337    }
338
339    // --- Lambda targets ---
340    for lambda in &stub.lambda_targets {
341        let lambda_label = format!("{}::{}", lambda.owner_fqn, lambda.method_name);
342        let lambda_name_id = helper.intern(&lambda_label)?;
343        let lambda_fqn = format!("{}.lambda${}", stub.fqn, lambda.method_name);
344        let lambda_qname_id = helper.intern(&lambda_fqn)?;
345
346        let lambda_entry = NodeEntry::new(NodeKind::LambdaTarget, lambda_name_id, file_id)
347            .with_qualified_name(lambda_qname_id);
348
349        let lambda_node_id = staging.add_node(lambda_entry);
350        fqn_to_node.insert(lambda_fqn, lambda_node_id);
351        metadata_store.insert_metadata(lambda_node_id, NodeMetadata::Classpath(cp_meta.clone()));
352
353        // Class → LambdaTarget: Contains
354        staging.add_edge(class_node_id, lambda_node_id, EdgeKind::Contains, file_id);
355    }
356
357    // --- Inner classes (Contains edge) ---
358    for inner in &stub.inner_classes {
359        // Only emit Contains edge if the inner class belongs to this outer class
360        // and has been separately emitted (will be linked post-emission).
361        if inner.outer_fqn.as_deref() == Some(&stub.fqn) {
362            // The inner class itself will be emitted as its own ClassStub.
363            // We record the relationship for edge creation in create_classpath_edges.
364            // Store the inner FQN in fqn_to_node so we can link later.
365            // Actual Contains edge is deferred to create_classpath_edges where
366            // both nodes are guaranteed to exist.
367        }
368    }
369
370    // --- Module info ---
371    if let Some(ref module) = stub.module {
372        let mod_name_id = helper.intern(&module.name)?;
373        let mod_fqn = format!("module:{}", module.name);
374        let mod_qname_id = helper.intern(&mod_fqn)?;
375
376        let mod_entry = NodeEntry::new(NodeKind::JavaModule, mod_name_id, file_id)
377            .with_qualified_name(mod_qname_id);
378
379        let mod_node_id = staging.add_node(mod_entry);
380        fqn_to_node.insert(mod_fqn, mod_node_id);
381        metadata_store.insert_metadata(mod_node_id, NodeMetadata::Classpath(cp_meta.clone()));
382
383        // Class → Module: Contains
384        staging.add_edge(class_node_id, mod_node_id, EdgeKind::Contains, file_id);
385    }
386
387    Ok(())
388}
389
390// `find_jar_path_for_stub` has been replaced by `stub.source_jar` inline
391// in `emit_class_stub`. The JAR path is now tracked per-stub during scanning,
392// eliminating the incorrect "first provenance entry" heuristic.
393
394/// Look up provenance for a JAR path.
395fn find_provenance_for_jar<'a>(
396    jar_path: &Path,
397    prov_map: &HashMap<&Path, &'a ClasspathProvenance>,
398) -> Option<&'a ClasspathProvenance> {
399    prov_map.get(jar_path).copied()
400}
401
402// ---------------------------------------------------------------------------
403// ExportMap registration (U15b)
404// ---------------------------------------------------------------------------
405
406/// Register classpath nodes in the `ExportMap` for cross-file resolution.
407///
408/// FQN precedence: workspace > direct dep > transitive dep.
409/// Direct dependencies are registered before transitive dependencies so that
410/// `ExportMap::lookup()` (which returns the first entry) prefers them.
411///
412/// Only class-level nodes (not methods/fields) are registered, matching the
413/// Java import resolution model where imports resolve to types.
414#[allow(clippy::implicit_hasher)] // Standard HashMap is intentional for classpath emitter
415pub fn register_classpath_exports(
416    fqn_to_node: &HashMap<String, NodeId>,
417    export_map: &mut ExportMap,
418    provenance: &[ClasspathProvenance],
419    file_id_map: &HashMap<String, FileId>,
420    index: &ClasspathIndex,
421) {
422    // Build a set of class-level FQNs (not methods/fields).
423    let class_fqns: std::collections::HashSet<&str> =
424        index.classes.iter().map(|s| s.fqn.as_str()).collect();
425
426    // Register direct dependencies first for precedence.
427    let direct_jars: std::collections::HashSet<&Path> = provenance
428        .iter()
429        .filter(|p| p.is_direct)
430        .map(|p| p.jar_path.as_path())
431        .collect();
432
433    let transitive_jars: std::collections::HashSet<&Path> = provenance
434        .iter()
435        .filter(|p| !p.is_direct)
436        .map(|p| p.jar_path.as_path())
437        .collect();
438
439    // Phase 1: Register direct dependency classes.
440    register_exports_for_jars(
441        &class_fqns,
442        fqn_to_node,
443        export_map,
444        file_id_map,
445        &direct_jars,
446        provenance,
447    );
448
449    // Phase 2: Register transitive dependency classes.
450    register_exports_for_jars(
451        &class_fqns,
452        fqn_to_node,
453        export_map,
454        file_id_map,
455        &transitive_jars,
456        provenance,
457    );
458}
459
460/// Register exports for classes from a specific set of JARs.
461fn register_exports_for_jars(
462    class_fqns: &std::collections::HashSet<&str>,
463    fqn_to_node: &HashMap<String, NodeId>,
464    export_map: &mut ExportMap,
465    file_id_map: &HashMap<String, FileId>,
466    _jar_filter: &std::collections::HashSet<&Path>,
467    _provenance: &[ClasspathProvenance],
468) {
469    for fqn in class_fqns {
470        if let (Some(&node_id), Some(&file_id)) = (fqn_to_node.get(*fqn), file_id_map.get(*fqn)) {
471            export_map.register((*fqn).to_owned(), file_id, node_id);
472        }
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Cross-reference edge creation (U15b)
478// ---------------------------------------------------------------------------
479
480/// Create inheritance, generic, annotation, module, and inner-class edges
481/// for classpath nodes.
482///
483/// Only creates edges where both source and target nodes exist in the graph.
484/// Missing targets are silently skipped (they may be from JARs not on the
485/// classpath).
486#[allow(clippy::too_many_lines)]
487#[allow(clippy::implicit_hasher)] // Standard HashMap intentional
488pub fn create_classpath_edges(
489    #[allow(clippy::implicit_hasher)] // Standard HashMap is intentional
490    index: &ClasspathIndex,
491    fqn_to_node: &HashMap<String, NodeId>,
492    staging: &mut StagingGraph,
493    file_id_map: &HashMap<String, FileId>,
494) {
495    for stub in &index.classes {
496        let Some(&class_node_id) = fqn_to_node.get(&stub.fqn) else {
497            continue;
498        };
499        let Some(&file_id) = file_id_map.get(&stub.fqn) else {
500            continue;
501        };
502
503        // --- Inheritance (Inherits) ---
504        if let Some(ref superclass_fqn) = stub.superclass
505            && superclass_fqn != "java.lang.Object"
506        {
507            if let Some(&super_node_id) = fqn_to_node.get(superclass_fqn) {
508                staging.add_edge(class_node_id, super_node_id, EdgeKind::Inherits, file_id);
509            } else {
510                log::debug!(
511                    "classpath: skipping Inherits edge for {} → {} (target not in graph)",
512                    stub.fqn,
513                    superclass_fqn
514                );
515            }
516        }
517
518        // --- Interface implementation (Implements) ---
519        for iface_fqn in &stub.interfaces {
520            if let Some(&iface_node_id) = fqn_to_node.get(iface_fqn) {
521                staging.add_edge(class_node_id, iface_node_id, EdgeKind::Implements, file_id);
522            } else {
523                log::debug!(
524                    "classpath: skipping Implements edge for {} → {} (target not in graph)",
525                    stub.fqn,
526                    iface_fqn
527                );
528            }
529        }
530
531        // --- Generic bounds (GenericBound) ---
532        if let Some(ref gen_sig) = stub.generic_signature {
533            for tp in &gen_sig.type_parameters {
534                let tp_fqn = format!("{}.<{}>", stub.fqn, tp.name);
535                let Some(&tp_node_id) = fqn_to_node.get(&tp_fqn) else {
536                    continue;
537                };
538
539                // Class bound
540                if let Some(ref bound) = tp.class_bound
541                    && let Some(bound_fqn) = extract_class_fqn_from_type_sig(bound)
542                    && let Some(&bound_node_id) = fqn_to_node.get(bound_fqn)
543                {
544                    staging.add_edge(tp_node_id, bound_node_id, EdgeKind::GenericBound, file_id);
545                }
546
547                // Interface bounds
548                for ibound in &tp.interface_bounds {
549                    if let Some(bound_fqn) = extract_class_fqn_from_type_sig(ibound)
550                        && let Some(&bound_node_id) = fqn_to_node.get(bound_fqn)
551                    {
552                        staging.add_edge(
553                            tp_node_id,
554                            bound_node_id,
555                            EdgeKind::GenericBound,
556                            file_id,
557                        );
558                    }
559                }
560            }
561        }
562
563        // --- Annotations (AnnotatedWith) ---
564        for ann in &stub.annotations {
565            if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
566                staging.add_edge(
567                    class_node_id,
568                    ann_type_node_id,
569                    EdgeKind::AnnotatedWith,
570                    file_id,
571                );
572            }
573        }
574
575        // Method-level annotations
576        for method in &stub.methods {
577            let method_fqn = format!("{}.{}{}", stub.fqn, method.name, method.descriptor);
578            if let Some(&method_node_id) = fqn_to_node.get(&method_fqn) {
579                for ann in &method.annotations {
580                    if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
581                        staging.add_edge(
582                            method_node_id,
583                            ann_type_node_id,
584                            EdgeKind::AnnotatedWith,
585                            file_id,
586                        );
587                    }
588                }
589            }
590        }
591
592        // Field-level annotations
593        for field in &stub.fields {
594            let field_fqn = format!("{}.{}", stub.fqn, field.name);
595            if let Some(&field_node_id) = fqn_to_node.get(&field_fqn) {
596                for ann in &field.annotations {
597                    if let Some(&ann_type_node_id) = fqn_to_node.get(&ann.type_fqn) {
598                        staging.add_edge(
599                            field_node_id,
600                            ann_type_node_id,
601                            EdgeKind::AnnotatedWith,
602                            file_id,
603                        );
604                    }
605                }
606            }
607        }
608
609        // --- Inner classes (Contains) ---
610        for inner in &stub.inner_classes {
611            if inner.outer_fqn.as_deref() == Some(&stub.fqn)
612                && let Some(&inner_node_id) = fqn_to_node.get(&inner.inner_fqn)
613            {
614                staging.add_edge(class_node_id, inner_node_id, EdgeKind::Contains, file_id);
615            }
616        }
617
618        // --- Module edges ---
619        if let Some(ref module) = stub.module {
620            let mod_fqn = format!("module:{}", module.name);
621            let Some(&mod_node_id) = fqn_to_node.get(&mod_fqn) else {
622                continue;
623            };
624
625            // ModuleRequires
626            for req in &module.requires {
627                let req_mod_fqn = format!("module:{}", req.module_name);
628                if let Some(&req_node_id) = fqn_to_node.get(&req_mod_fqn) {
629                    staging.add_edge(mod_node_id, req_node_id, EdgeKind::ModuleRequires, file_id);
630                }
631            }
632
633            // ModuleExports - edge from module to classes in exported package
634            for exp in &module.exports {
635                // Look up classes in the exported package
636                let pkg_classes = index.lookup_package(&exp.package);
637                for pkg_class in pkg_classes {
638                    if let Some(&pkg_class_node_id) = fqn_to_node.get(&pkg_class.fqn) {
639                        staging.add_edge(
640                            mod_node_id,
641                            pkg_class_node_id,
642                            EdgeKind::ModuleExports,
643                            file_id,
644                        );
645                    }
646                }
647            }
648
649            // ModuleOpens - edge from module to classes in opened package
650            for opens in &module.opens {
651                let pkg_classes = index.lookup_package(&opens.package);
652                for pkg_class in pkg_classes {
653                    if let Some(&pkg_class_node_id) = fqn_to_node.get(&pkg_class.fqn) {
654                        staging.add_edge(
655                            mod_node_id,
656                            pkg_class_node_id,
657                            EdgeKind::ModuleOpens,
658                            file_id,
659                        );
660                    }
661                }
662            }
663
664            // ModuleProvides - edge from module to service implementation classes
665            for provides in &module.provides {
666                for impl_fqn in &provides.implementations {
667                    if let Some(&impl_node_id) = fqn_to_node.get(impl_fqn) {
668                        staging.add_edge(
669                            mod_node_id,
670                            impl_node_id,
671                            EdgeKind::ModuleProvides,
672                            file_id,
673                        );
674                    }
675                }
676            }
677        }
678    }
679}
680
681/// Extract the FQN from a `TypeSignature::Class` variant.
682fn extract_class_fqn_from_type_sig(sig: &crate::stub::model::TypeSignature) -> Option<&str> {
683    match sig {
684        crate::stub::model::TypeSignature::Class { fqn, .. } => Some(fqn.as_str()),
685        _ => None,
686    }
687}
688
689// ---------------------------------------------------------------------------
690// Tests
691// ---------------------------------------------------------------------------
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::stub::model::{
697        AccessFlags, AnnotationStub, ClassKind, FieldStub, GenericClassSignature, InnerClassEntry,
698        LambdaTargetStub, MethodStub, ModuleExports, ModuleProvides, ModuleRequires, ModuleStub,
699        ReferenceKind, TypeParameterStub, TypeSignature,
700    };
701
702    // -----------------------------------------------------------------------
703    // Test helpers
704    // -----------------------------------------------------------------------
705
706    fn make_interner() -> StringInterner {
707        StringInterner::new()
708    }
709
710    fn make_staging() -> StagingGraph {
711        StagingGraph::default()
712    }
713
714    fn make_provenance(jar: &str, direct: bool) -> ClasspathProvenance {
715        ClasspathProvenance {
716            jar_path: PathBuf::from(jar),
717            coordinates: Some(format!(
718                "group:artifact:{}",
719                if direct { "1.0" } else { "2.0" }
720            )),
721            is_direct: direct,
722        }
723    }
724
725    fn make_stub(fqn: &str) -> ClassStub {
726        ClassStub {
727            fqn: fqn.to_owned(),
728            name: fqn.rsplit('.').next().unwrap_or(fqn).to_owned(),
729            kind: ClassKind::Class,
730            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
731            superclass: Some("java.lang.Object".to_owned()),
732            interfaces: vec![],
733            methods: vec![],
734            fields: vec![],
735            annotations: vec![],
736            generic_signature: None,
737            inner_classes: vec![],
738            lambda_targets: vec![],
739            module: None,
740            record_components: vec![],
741            enum_constants: vec![],
742            source_file: None,
743            source_jar: None,
744            kotlin_metadata: None,
745            scala_signature: None,
746        }
747    }
748
749    fn make_method(name: &str) -> MethodStub {
750        MethodStub {
751            name: name.to_owned(),
752            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
753            descriptor: "()V".to_owned(),
754            generic_signature: None,
755            annotations: vec![],
756            parameter_annotations: vec![],
757            parameter_names: vec![],
758            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
759            parameter_types: vec![],
760        }
761    }
762
763    fn make_field(name: &str) -> FieldStub {
764        FieldStub {
765            name: name.to_owned(),
766            access: AccessFlags::new(AccessFlags::ACC_PRIVATE),
767            descriptor: "I".to_owned(),
768            generic_signature: None,
769            annotations: vec![],
770            constant_value: None,
771        }
772    }
773
774    /// Run emission and return the result plus the staging graph for inspection.
775    fn run_emission(
776        stubs: Vec<ClassStub>,
777        provenance: &[ClasspathProvenance],
778    ) -> (
779        EmissionResult,
780        StagingGraph,
781        FileRegistry,
782        StringInterner,
783        NodeMetadataStore,
784    ) {
785        let index = ClasspathIndex::build(stubs);
786        let mut staging = make_staging();
787        let mut file_registry = FileRegistry::new();
788        let mut interner = make_interner();
789        let mut metadata_store = NodeMetadataStore::new();
790
791        let result = emit_classpath_nodes(
792            &index,
793            &mut staging,
794            &mut file_registry,
795            &mut interner,
796            &mut metadata_store,
797            provenance,
798        )
799        .expect("emission should succeed");
800
801        (result, staging, file_registry, interner, metadata_store)
802    }
803
804    // -----------------------------------------------------------------------
805    // Test 1: Simple class emits correct nodes
806    // -----------------------------------------------------------------------
807
808    #[test]
809    fn test_simple_class_emits_nodes() {
810        let mut stub = make_stub("com.example.Foo");
811        stub.methods = vec![make_method("bar"), make_method("baz")];
812        stub.fields = vec![make_field("count")];
813
814        let prov = vec![make_provenance("/jars/example.jar", true)];
815        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
816
817        // Class node
818        assert!(
819            result.fqn_to_node.contains_key("com.example.Foo"),
820            "class node should be emitted"
821        );
822
823        // Method nodes (key includes descriptor for overload disambiguation)
824        assert!(
825            result.fqn_to_node.contains_key("com.example.Foo.bar()V"),
826            "method 'bar' should be emitted"
827        );
828        assert!(
829            result.fqn_to_node.contains_key("com.example.Foo.baz()V"),
830            "method 'baz' should be emitted"
831        );
832
833        // Field node
834        assert!(
835            result.fqn_to_node.contains_key("com.example.Foo.count"),
836            "field 'count' should be emitted"
837        );
838
839        // Total: 1 class + 2 methods + 1 field = 4
840        assert_eq!(result.fqn_to_node.len(), 4);
841    }
842
843    // -----------------------------------------------------------------------
844    // Test 2: Inheritance edge
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn test_inheritance_edge_created() {
849        let mut list = make_stub("java.util.AbstractList");
850        list.superclass = Some("java.util.AbstractCollection".to_owned());
851
852        let collection = make_stub("java.util.AbstractCollection");
853
854        let prov = vec![make_provenance("/jars/rt.jar", true)];
855        let stubs = vec![list, collection];
856        let index = ClasspathIndex::build(stubs.clone());
857        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
858
859        create_classpath_edges(
860            &index,
861            &result.fqn_to_node,
862            &mut staging,
863            &result.file_id_map,
864        );
865
866        // Verify the AbstractList node exists
867        assert!(result.fqn_to_node.contains_key("java.util.AbstractList"));
868        assert!(
869            result
870                .fqn_to_node
871                .contains_key("java.util.AbstractCollection")
872        );
873
874        // Edge verification happens through staging stats
875        let stats = staging.stats();
876        // Structural edges (Defines for methods/fields) + Inherits edge
877        assert!(
878            stats.edges_staged > 0,
879            "should have at least one edge staged"
880        );
881    }
882
883    // -----------------------------------------------------------------------
884    // Test 3: Interface implementation edge
885    // -----------------------------------------------------------------------
886
887    #[test]
888    fn test_implements_edge_created() {
889        let mut array_list = make_stub("java.util.ArrayList");
890        array_list.interfaces = vec![
891            "java.util.List".to_owned(),
892            "java.io.Serializable".to_owned(),
893        ];
894
895        let list_iface = {
896            let mut s = make_stub("java.util.List");
897            s.kind = ClassKind::Interface;
898            s
899        };
900
901        let serializable = {
902            let mut s = make_stub("java.io.Serializable");
903            s.kind = ClassKind::Interface;
904            s
905        };
906
907        let prov = vec![make_provenance("/jars/rt.jar", true)];
908        let stubs = vec![array_list, list_iface, serializable];
909        let index = ClasspathIndex::build(stubs.clone());
910        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
911
912        create_classpath_edges(
913            &index,
914            &result.fqn_to_node,
915            &mut staging,
916            &result.file_id_map,
917        );
918
919        // All three should exist
920        assert!(result.fqn_to_node.contains_key("java.util.ArrayList"));
921        assert!(result.fqn_to_node.contains_key("java.util.List"));
922        assert!(result.fqn_to_node.contains_key("java.io.Serializable"));
923    }
924
925    // -----------------------------------------------------------------------
926    // Test 4: ExportMap registration and lookup
927    // -----------------------------------------------------------------------
928
929    #[test]
930    fn test_export_map_registration_and_lookup() {
931        let stubs = vec![
932            make_stub("com.example.Alpha"),
933            make_stub("com.example.Beta"),
934        ];
935
936        let prov = vec![make_provenance("/jars/example.jar", true)];
937        let index = ClasspathIndex::build(stubs.clone());
938        let (result, _staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
939
940        let mut export_map = ExportMap::new();
941        register_classpath_exports(
942            &result.fqn_to_node,
943            &mut export_map,
944            &prov,
945            &result.file_id_map,
946            &index,
947        );
948
949        // Lookup should find the registered classes
950        let alpha = export_map.lookup("com.example.Alpha");
951        assert!(alpha.is_some(), "Alpha should be in ExportMap");
952
953        let beta = export_map.lookup("com.example.Beta");
954        assert!(beta.is_some(), "Beta should be in ExportMap");
955
956        // Non-existent should return None
957        let missing = export_map.lookup("com.example.DoesNotExist");
958        assert!(missing.is_none());
959    }
960
961    // -----------------------------------------------------------------------
962    // Test 5: FQN precedence (direct > transitive)
963    // -----------------------------------------------------------------------
964
965    #[test]
966    fn test_fqn_precedence_direct_before_transitive() {
967        // Two stubs with same FQN but from different JARs won't happen in practice
968        // since ClasspathIndex deduplicates. But we verify direct deps register first.
969        let stub = make_stub("com.example.Foo");
970        let prov_direct = make_provenance("/jars/direct.jar", true);
971        let prov_transitive = make_provenance("/jars/transitive.jar", false);
972
973        let index = ClasspathIndex::build(vec![stub]);
974        let (result, _staging, _registry, _interner, _meta) = run_emission(
975            index.classes.clone(),
976            &[prov_direct.clone(), prov_transitive],
977        );
978
979        let mut export_map = ExportMap::new();
980        register_classpath_exports(
981            &result.fqn_to_node,
982            &mut export_map,
983            &[prov_direct, make_provenance("/jars/transitive.jar", false)],
984            &result.file_id_map,
985            &index,
986        );
987
988        // Should find the class
989        assert!(export_map.lookup("com.example.Foo").is_some());
990    }
991
992    // -----------------------------------------------------------------------
993    // Test 6: ClasspathNodeMetadata attached correctly
994    // -----------------------------------------------------------------------
995
996    #[test]
997    fn test_classpath_metadata_attached() {
998        let stub = make_stub("com.google.common.collect.ImmutableList");
999        let prov = vec![ClasspathProvenance {
1000            jar_path: PathBuf::from("/home/user/.m2/repository/guava-33.0.0.jar"),
1001            coordinates: Some("com.google.guava:guava:33.0.0".to_owned()),
1002            is_direct: true,
1003        }];
1004
1005        let (result, _staging, _registry, _interner, metadata_store) =
1006            run_emission(vec![stub], &prov);
1007
1008        let node_id = result
1009            .fqn_to_node
1010            .get("com.google.common.collect.ImmutableList")
1011            .expect("node should exist");
1012
1013        let metadata = metadata_store
1014            .get_metadata(*node_id)
1015            .expect("metadata should be attached");
1016
1017        match metadata {
1018            NodeMetadata::Classpath(cp) => {
1019                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
1020                assert_eq!(
1021                    cp.coordinates.as_deref(),
1022                    Some("com.google.guava:guava:33.0.0")
1023                );
1024                assert!(cp.is_direct_dependency);
1025                assert!(cp.jar_path.contains("guava-33.0.0.jar"));
1026            }
1027            NodeMetadata::Macro(_) => panic!("expected Classpath metadata, got Macro"),
1028        }
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // Test 7: Zero spans on all nodes
1033    // -----------------------------------------------------------------------
1034
1035    #[test]
1036    fn test_zero_spans_on_classpath_nodes() {
1037        let mut stub = make_stub("com.example.ZeroSpan");
1038        stub.methods = vec![make_method("doWork")];
1039
1040        let prov = vec![make_provenance("/jars/test.jar", true)];
1041        let (result, staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1042
1043        // All emitted nodes should have zero spans since they come from bytecode.
1044        // The NodeEntry::new constructor sets all span fields to 0 by default,
1045        // and we don't override them.
1046        assert!(
1047            !result.fqn_to_node.is_empty(),
1048            "should have emitted at least one node"
1049        );
1050
1051        // Verify through staging stats that nodes were emitted
1052        let stats = staging.stats();
1053        assert!(
1054            stats.nodes_staged >= 2,
1055            "should have at least 2 nodes (class + method)"
1056        );
1057    }
1058
1059    // -----------------------------------------------------------------------
1060    // Test 8: Annotation edges
1061    // -----------------------------------------------------------------------
1062
1063    #[test]
1064    fn test_annotation_edges() {
1065        let mut stub = make_stub("com.example.MyService");
1066        stub.annotations = vec![AnnotationStub {
1067            type_fqn: "org.springframework.stereotype.Service".to_owned(),
1068            elements: vec![],
1069            is_runtime_visible: true,
1070        }];
1071
1072        // Also emit the annotation type so the edge can be created
1073        let ann_type = {
1074            let mut s = make_stub("org.springframework.stereotype.Service");
1075            s.kind = ClassKind::Annotation;
1076            s
1077        };
1078
1079        let prov = vec![make_provenance("/jars/app.jar", true)];
1080        let stubs = vec![stub, ann_type];
1081        let index = ClasspathIndex::build(stubs.clone());
1082        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1083
1084        create_classpath_edges(
1085            &index,
1086            &result.fqn_to_node,
1087            &mut staging,
1088            &result.file_id_map,
1089        );
1090
1091        // Both nodes should exist
1092        assert!(result.fqn_to_node.contains_key("com.example.MyService"));
1093        assert!(
1094            result
1095                .fqn_to_node
1096                .contains_key("org.springframework.stereotype.Service")
1097        );
1098
1099        // Edge verification through staging stats
1100        let stats = staging.stats();
1101        assert!(
1102            stats.edges_staged > 0,
1103            "should have annotation edges staged"
1104        );
1105    }
1106
1107    // -----------------------------------------------------------------------
1108    // Test 9: Module edges
1109    // -----------------------------------------------------------------------
1110
1111    #[test]
1112    fn test_module_edges() {
1113        let provider_class = make_stub("com.example.spi.MyProvider");
1114        let exported_class = make_stub("com.example.api.MyApi");
1115
1116        let mut module_stub = make_stub("module-info");
1117        module_stub.kind = ClassKind::Module;
1118        module_stub.module = Some(ModuleStub {
1119            name: "com.example".to_owned(),
1120            access: AccessFlags::new(0),
1121            version: None,
1122            requires: vec![ModuleRequires {
1123                module_name: "java.base".to_owned(),
1124                access: AccessFlags::new(0),
1125                version: None,
1126            }],
1127            exports: vec![ModuleExports {
1128                package: "com.example.api".to_owned(),
1129                access: AccessFlags::new(0),
1130                to_modules: vec![],
1131            }],
1132            opens: vec![],
1133            provides: vec![ModuleProvides {
1134                service: "com.example.spi.SomeService".to_owned(),
1135                implementations: vec!["com.example.spi.MyProvider".to_owned()],
1136            }],
1137            uses: vec![],
1138        });
1139
1140        let prov = vec![make_provenance("/jars/example.jar", true)];
1141        let stubs = vec![module_stub, provider_class, exported_class];
1142        let index = ClasspathIndex::build(stubs.clone());
1143        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1144
1145        create_classpath_edges(
1146            &index,
1147            &result.fqn_to_node,
1148            &mut staging,
1149            &result.file_id_map,
1150        );
1151
1152        // Module node should exist
1153        assert!(
1154            result.fqn_to_node.contains_key("module:com.example"),
1155            "module node should be emitted"
1156        );
1157
1158        let stats = staging.stats();
1159        assert!(stats.edges_staged > 0, "should have module edges staged");
1160    }
1161
1162    // -----------------------------------------------------------------------
1163    // Test 10: Enum constants
1164    // -----------------------------------------------------------------------
1165
1166    #[test]
1167    fn test_enum_constants_emitted() {
1168        let mut stub = make_stub("java.time.DayOfWeek");
1169        stub.kind = ClassKind::Enum;
1170        stub.enum_constants = vec![
1171            "MONDAY".to_owned(),
1172            "TUESDAY".to_owned(),
1173            "WEDNESDAY".to_owned(),
1174        ];
1175
1176        let prov = vec![make_provenance("/jars/rt.jar", true)];
1177        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1178
1179        assert!(
1180            result
1181                .fqn_to_node
1182                .contains_key("java.time.DayOfWeek.MONDAY")
1183        );
1184        assert!(
1185            result
1186                .fqn_to_node
1187                .contains_key("java.time.DayOfWeek.TUESDAY")
1188        );
1189        assert!(
1190            result
1191                .fqn_to_node
1192                .contains_key("java.time.DayOfWeek.WEDNESDAY")
1193        );
1194    }
1195
1196    // -----------------------------------------------------------------------
1197    // Test 11: Type parameters emitted
1198    // -----------------------------------------------------------------------
1199
1200    #[test]
1201    fn test_type_parameters_emitted() {
1202        let mut stub = make_stub("java.util.HashMap");
1203        stub.generic_signature = Some(GenericClassSignature {
1204            type_parameters: vec![
1205                TypeParameterStub {
1206                    name: "K".to_owned(),
1207                    class_bound: None,
1208                    interface_bounds: vec![],
1209                },
1210                TypeParameterStub {
1211                    name: "V".to_owned(),
1212                    class_bound: None,
1213                    interface_bounds: vec![],
1214                },
1215            ],
1216            superclass: TypeSignature::Class {
1217                fqn: "java.util.AbstractMap".to_owned(),
1218                type_arguments: vec![],
1219            },
1220            interfaces: vec![],
1221        });
1222
1223        let prov = vec![make_provenance("/jars/rt.jar", true)];
1224        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1225
1226        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<K>"));
1227        assert!(result.fqn_to_node.contains_key("java.util.HashMap.<V>"));
1228    }
1229
1230    // -----------------------------------------------------------------------
1231    // Test 12: Generic bound edges
1232    // -----------------------------------------------------------------------
1233
1234    #[test]
1235    fn test_generic_bound_edges() {
1236        let comparable = make_stub("java.lang.Comparable");
1237
1238        let mut stub = make_stub("com.example.Sorted");
1239        stub.generic_signature = Some(GenericClassSignature {
1240            type_parameters: vec![TypeParameterStub {
1241                name: "T".to_owned(),
1242                class_bound: Some(TypeSignature::Class {
1243                    fqn: "java.lang.Comparable".to_owned(),
1244                    type_arguments: vec![],
1245                }),
1246                interface_bounds: vec![],
1247            }],
1248            superclass: TypeSignature::Class {
1249                fqn: "java.lang.Object".to_owned(),
1250                type_arguments: vec![],
1251            },
1252            interfaces: vec![],
1253        });
1254
1255        let prov = vec![make_provenance("/jars/rt.jar", true)];
1256        let stubs = vec![stub, comparable];
1257        let index = ClasspathIndex::build(stubs.clone());
1258        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1259
1260        create_classpath_edges(
1261            &index,
1262            &result.fqn_to_node,
1263            &mut staging,
1264            &result.file_id_map,
1265        );
1266
1267        // Type parameter and bound target should exist
1268        assert!(result.fqn_to_node.contains_key("com.example.Sorted.<T>"));
1269        assert!(result.fqn_to_node.contains_key("java.lang.Comparable"));
1270    }
1271
1272    // -----------------------------------------------------------------------
1273    // Test 13: Lambda targets emitted
1274    // -----------------------------------------------------------------------
1275
1276    #[test]
1277    fn test_lambda_targets_emitted() {
1278        let mut stub = make_stub("com.example.Processor");
1279        stub.lambda_targets = vec![LambdaTargetStub {
1280            owner_fqn: "java.lang.String".to_owned(),
1281            method_name: "toUpperCase".to_owned(),
1282            method_descriptor: "()Ljava/lang/String;".to_owned(),
1283            reference_kind: ReferenceKind::InvokeVirtual,
1284        }];
1285
1286        let prov = vec![make_provenance("/jars/app.jar", true)];
1287        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1288
1289        assert!(
1290            result
1291                .fqn_to_node
1292                .contains_key("com.example.Processor.lambda$toUpperCase")
1293        );
1294    }
1295
1296    // -----------------------------------------------------------------------
1297    // Test 14: Inner class Contains edge
1298    // -----------------------------------------------------------------------
1299
1300    #[test]
1301    fn test_inner_class_contains_edge() {
1302        let outer = {
1303            let mut s = make_stub("com.example.Outer");
1304            s.inner_classes = vec![InnerClassEntry {
1305                inner_fqn: "com.example.Outer.Inner".to_owned(),
1306                outer_fqn: Some("com.example.Outer".to_owned()),
1307                inner_name: Some("Inner".to_owned()),
1308                access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1309            }];
1310            s
1311        };
1312
1313        let inner = make_stub("com.example.Outer.Inner");
1314
1315        let prov = vec![make_provenance("/jars/app.jar", true)];
1316        let stubs = vec![outer, inner];
1317        let index = ClasspathIndex::build(stubs.clone());
1318        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1319
1320        create_classpath_edges(
1321            &index,
1322            &result.fqn_to_node,
1323            &mut staging,
1324            &result.file_id_map,
1325        );
1326
1327        assert!(result.fqn_to_node.contains_key("com.example.Outer"));
1328        assert!(result.fqn_to_node.contains_key("com.example.Outer.Inner"));
1329    }
1330
1331    // -----------------------------------------------------------------------
1332    // Test 15: Empty index produces empty result
1333    // -----------------------------------------------------------------------
1334
1335    #[test]
1336    fn test_empty_index_no_nodes() {
1337        let prov: Vec<ClasspathProvenance> = vec![];
1338        let (result, staging, _registry, _interner, _meta) = run_emission(vec![], &prov);
1339
1340        assert!(result.fqn_to_node.is_empty());
1341        assert_eq!(staging.stats().nodes_staged, 0);
1342    }
1343
1344    // -----------------------------------------------------------------------
1345    // Test 16: Synthetic file path convention
1346    // -----------------------------------------------------------------------
1347
1348    #[test]
1349    fn test_synthetic_file_path_convention() {
1350        let stub = make_stub("com.example.Foo");
1351        let prov = vec![make_provenance("/jars/example.jar", true)];
1352        let (result, _staging, file_registry, _interner, _meta) = run_emission(vec![stub], &prov);
1353
1354        let file_id = result
1355            .file_id_map
1356            .get("com.example.Foo")
1357            .expect("file ID should exist");
1358
1359        let path = file_registry
1360            .resolve(*file_id)
1361            .expect("file should be resolvable");
1362
1363        let path_str = path.to_string_lossy();
1364        assert!(
1365            path_str.contains("!/com/example/Foo.class"),
1366            "synthetic path should follow JAR convention, got: {path_str}"
1367        );
1368    }
1369
1370    // -----------------------------------------------------------------------
1371    // Test 17: Interface kind maps correctly
1372    // -----------------------------------------------------------------------
1373
1374    #[test]
1375    fn test_interface_kind_mapping() {
1376        let mut stub = make_stub("java.util.List");
1377        stub.kind = ClassKind::Interface;
1378
1379        let prov = vec![make_provenance("/jars/rt.jar", true)];
1380        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &prov);
1381
1382        assert!(result.fqn_to_node.contains_key("java.util.List"));
1383    }
1384
1385    // -----------------------------------------------------------------------
1386    // Test 18: Method-level annotation edge
1387    // -----------------------------------------------------------------------
1388
1389    #[test]
1390    fn test_method_level_annotation_edge() {
1391        let override_ann = {
1392            let mut s = make_stub("java.lang.Override");
1393            s.kind = ClassKind::Annotation;
1394            s
1395        };
1396
1397        let mut stub = make_stub("com.example.Foo");
1398        stub.methods = vec![MethodStub {
1399            name: "toString".to_owned(),
1400            access: AccessFlags::new(AccessFlags::ACC_PUBLIC),
1401            descriptor: "()Ljava/lang/String;".to_owned(),
1402            generic_signature: None,
1403            annotations: vec![AnnotationStub {
1404                type_fqn: "java.lang.Override".to_owned(),
1405                elements: vec![],
1406                is_runtime_visible: true,
1407            }],
1408            parameter_annotations: vec![],
1409            parameter_names: vec![],
1410            return_type: TypeSignature::Base(crate::stub::model::BaseType::Void),
1411            parameter_types: vec![],
1412        }];
1413
1414        let prov = vec![make_provenance("/jars/rt.jar", true)];
1415        let stubs = vec![stub, override_ann];
1416        let index = ClasspathIndex::build(stubs.clone());
1417        let (result, mut staging, _registry, _interner, _meta) = run_emission(stubs, &prov);
1418
1419        create_classpath_edges(
1420            &index,
1421            &result.fqn_to_node,
1422            &mut staging,
1423            &result.file_id_map,
1424        );
1425
1426        assert!(
1427            result
1428                .fqn_to_node
1429                .contains_key("com.example.Foo.toString()Ljava/lang/String;")
1430        );
1431        assert!(result.fqn_to_node.contains_key("java.lang.Override"));
1432    }
1433
1434    // -----------------------------------------------------------------------
1435    // Test 19: No provenance still emits nodes
1436    // -----------------------------------------------------------------------
1437
1438    #[test]
1439    fn test_no_provenance_still_emits() {
1440        let stub = make_stub("com.example.NoProv");
1441        let (result, _staging, _registry, _interner, _meta) = run_emission(vec![stub], &[]);
1442
1443        assert!(result.fqn_to_node.contains_key("com.example.NoProv"));
1444    }
1445
1446    // -----------------------------------------------------------------------
1447    // Test 20: Visibility mapping
1448    // -----------------------------------------------------------------------
1449
1450    #[test]
1451    fn test_visibility_mapping() {
1452        assert_eq!(
1453            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PUBLIC)),
1454            "public"
1455        );
1456        assert_eq!(
1457            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PROTECTED)),
1458            "protected"
1459        );
1460        assert_eq!(
1461            access_to_visibility(&AccessFlags::new(AccessFlags::ACC_PRIVATE)),
1462            "private"
1463        );
1464        assert_eq!(access_to_visibility(&AccessFlags::empty()), "package");
1465    }
1466}