agpm_cli/templating/dependencies/
builders.rs

1//! Dependency building functionality for templates.
2//!
3//! This module provides helper functions for building dependency data
4//! structures used in template rendering.
5
6use anyhow::{Context as _, Result};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::core::ResourceType;
12use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
13use crate::lockfile::{LockedResource, ResourceId};
14
15use super::extractors::{DependencyExtractor, create_dependency_ref_string};
16use crate::templating::cache::RenderCacheKey;
17use crate::templating::content::{
18    NON_TEMPLATED_LITERAL_GUARD_START, content_contains_template_syntax,
19};
20use crate::templating::context::DependencyData;
21use crate::templating::renderer::TemplateRenderer;
22use crate::templating::utils::to_native_path_display;
23
24/// Build dependency data for the template context.
25///
26/// This creates a nested structure containing:
27/// 1. ALL resources from the lockfile (path-based names) - for universal access
28/// 2. Current resource's declared dependencies (custom alias names) - for scoped access
29///
30/// This dual approach ensures:
31/// - Any resource can access any other resource via path-based names
32/// - Resources can use custom aliases for their dependencies without collisions
33///
34/// # Arguments
35///
36/// * `extractor` - The dependency extractor implementation
37/// * `current_resource` - The resource being rendered (for scoped alias mapping)
38/// * `rendering_stack` - Stack for cycle detection
39pub(crate) async fn build_dependencies_data<T: DependencyExtractor>(
40    extractor: &T,
41    current_resource: &LockedResource,
42    rendering_stack: &mut HashSet<String>,
43) -> Result<BTreeMap<String, BTreeMap<String, DependencyData>>> {
44    let mut deps = BTreeMap::new();
45
46    // Extract dependency specifications from current resource's frontmatter
47    // This provides tool, name, flatten, and install fields for each dependency
48    let dependency_specs =
49        extractor.extract_dependency_specs(current_resource).await.with_context(|| {
50            format!(
51                "Failed to extract dependency specifications from resource '{}' (type: {:?})",
52                current_resource.name, current_resource.resource_type
53            )
54        })?;
55
56    // Helper function to determine the key name for a resource
57    let get_key_names =
58        |resource: &LockedResource, dep_type: &ResourceType| -> (String, String, String, String) {
59            let type_str_plural = dep_type.to_plural().to_string();
60            let type_str_singular = dep_type.to_string();
61
62            // Determine the key to use for universal access in the template context
63            // DO NOT use manifest_alias - it's only for pattern aliases from manifest,
64            // not transitive custom names which are extracted during template rendering
65            let key_name = if resource.name.contains('/') || resource.name.contains('\\') {
66                // Name looks like a path - extract basename without extension
67                std::path::Path::new(&resource.name)
68                    .file_stem()
69                    .and_then(|s| s.to_str())
70                    .unwrap_or(&resource.name)
71                    .to_string()
72            } else {
73                // Use name as-is
74                resource.name.clone()
75            };
76
77            // Sanitize the key name by replacing hyphens with underscores
78            // to avoid Tera interpreting them as minus operators
79            let sanitized_key = key_name.replace('-', "_");
80
81            (type_str_plural, type_str_singular, key_name, sanitized_key)
82        };
83
84    // Collect ONLY direct dependencies (not transitive!)
85    // Each dependency will be rendered with its own context containing its own direct deps.
86    let mut resources_to_process: Vec<(&LockedResource, ResourceType, bool)> = Vec::new();
87    let mut visited_dep_ids = HashSet::new();
88
89    for dep_ref in current_resource.parsed_dependencies() {
90        // Build dep_id for deduplication tracking
91        let dep_id = dep_ref.to_string();
92
93        // Skip if we've already processed this dependency
94        if !visited_dep_ids.insert(dep_id.clone()) {
95            continue;
96        }
97
98        let resource_type = dep_ref.resource_type;
99        let name = &dep_ref.path;
100
101        // Get the dependency spec for this reference (if declared in frontmatter)
102        // NOTE: dependency_specs keys are normalized (no ../ segments) because
103        // extract_dependency_specs normalizes paths using Path component iteration.
104        // We must normalize the lookup key to match.
105        let dep_spec = {
106            // Normalize the path to match what extract_dependency_specs stored
107            let normalized_path = {
108                let path = std::path::Path::new(&dep_ref.path);
109                let normalized = crate::utils::normalize_path(path);
110                normalized.to_string_lossy().to_string()
111            };
112
113            // Create a normalized dep_ref for cache lookup only
114            let normalized_dep_ref = LockfileDependencyRef::new(
115                dep_ref.source.clone(),
116                dep_ref.resource_type,
117                normalized_path,
118                dep_ref.version.clone(),
119            );
120            let normalized_dep_id = normalized_dep_ref.to_string();
121
122            dependency_specs.get(&normalized_dep_id)
123        };
124
125        tracing::debug!(
126            "Looking up dep_spec for dep_id='{}', found={}, available_keys={:?}",
127            dep_id,
128            dep_spec.is_some(),
129            dependency_specs.keys().collect::<Vec<_>>()
130        );
131
132        // Determine the tool for this dependency
133        // Priority: explicit tool in DependencySpec > inherited from parent
134        let dep_tool =
135            dep_spec.and_then(|spec| spec.tool.as_ref()).or(current_resource.tool.as_ref());
136
137        // Determine the source for this dependency
138        // Use dep_ref.source if present, otherwise inherit from parent
139        let dep_source = dep_ref.source.as_ref().or(current_resource.source.as_ref());
140
141        // Build complete ResourceId for precise lookup
142        // Try parent's variant_inputs_hash first (for transitive deps that inherit context)
143        let dep_resource_id_with_parent_hash = ResourceId::new(
144            name.clone(),
145            dep_source.cloned(),
146            dep_tool.cloned(),
147            resource_type,
148            current_resource.variant_inputs.hash().to_string(),
149        );
150
151        tracing::debug!(
152            "[DEBUG] Template context looking up: name='{}', type={:?}, source={:?}, tool={:?}, hash={}",
153            name,
154            resource_type,
155            dep_source,
156            dep_tool,
157            &current_resource.variant_inputs.hash().to_string()[..8]
158        );
159
160        // Look up the dependency in the lockfile by full ResourceId
161        // Try with parent's hash first, then fall back to empty hash for direct manifest deps
162        let mut dep_resource =
163            extractor.lockfile().find_resource_by_id(&dep_resource_id_with_parent_hash);
164
165        // If not found with parent's hash, try with empty hash (direct manifest dependencies)
166        if dep_resource.is_none() {
167            let dep_resource_id_empty_hash = ResourceId::new(
168                name.clone(),
169                dep_source.cloned(),
170                dep_tool.cloned(),
171                resource_type,
172                crate::resolver::lockfile_builder::VariantInputs::default().hash().to_string(),
173            );
174            dep_resource = extractor.lockfile().find_resource_by_id(&dep_resource_id_empty_hash);
175
176            if dep_resource.is_some() {
177                tracing::debug!(
178                    "  [DIRECT MANIFEST DEP] Found dependency '{}' with empty variant_hash (direct manifest dependency)",
179                    name
180                );
181            }
182        }
183
184        if let Some(dep_resource) = dep_resource {
185            // Add this dependency to resources to process (true = declared dependency)
186            resources_to_process.push((dep_resource, resource_type, true));
187
188            tracing::debug!(
189                "  [DIRECT DEP] Found dependency '{}' (tool: {:?}) for '{}'",
190                name,
191                dep_tool,
192                current_resource.name
193            );
194        } else {
195            tracing::warn!(
196                "Dependency '{}' (type: {:?}, tool: {:?}) not found in lockfile for resource '{}'",
197                name,
198                resource_type,
199                dep_tool,
200                current_resource.name
201            );
202        }
203    }
204
205    tracing::debug!(
206        "Building dependencies data with {} direct dependencies for '{}'",
207        resources_to_process.len(),
208        current_resource.name
209    );
210
211    // CRITICAL: Sort resources_to_process for deterministic ordering!
212    // This ensures that even if resources were added in different orders,
213    // we process them in a consistent order, leading to deterministic context building.
214    // Sort by: (resource_type, name, is_dependency) for full determinism
215    resources_to_process.sort_by(|a, b| {
216        use std::cmp::Ordering;
217        // First by resource type
218        match a.1.cmp(&b.1) {
219            Ordering::Equal => {
220                // Then by resource name
221                match a.0.name.cmp(&b.0.name) {
222                    Ordering::Equal => {
223                        // Finally by is_dependency (dependencies first)
224                        b.2.cmp(&a.2) // Reverse to put true before false
225                    }
226                    other => other,
227                }
228            }
229            other => other,
230        }
231    });
232
233    // Debug: log all resources being processed
234    for (resource, dep_type, is_dep) in &resources_to_process {
235        tracing::debug!(
236            "  [LOCKFILE] Resource: {} (type: {:?}, install: {:?}, is_dependency: {})",
237            resource.name,
238            dep_type,
239            resource.install,
240            is_dep
241        );
242    }
243
244    // Get current resource ID for filtering
245    let current_resource_id = create_dependency_ref_string(
246        current_resource.source.clone(),
247        current_resource.resource_type,
248        current_resource.name.clone(),
249        current_resource.version.clone(),
250    );
251
252    // Process each resource (excluding the current resource to prevent self-reference)
253    for (resource, dep_type, is_dependency) in &resources_to_process {
254        let resource_id = create_dependency_ref_string(
255            resource.source.clone(),
256            *dep_type,
257            resource.name.clone(),
258            resource.version.clone(),
259        );
260
261        // Skip if this is the current resource (prevent self-dependency)
262        if resource_id == current_resource_id {
263            tracing::debug!(
264                "  Skipping current resource: {} (preventing self-reference)",
265                resource.name
266            );
267            continue;
268        }
269
270        tracing::debug!("  Processing resource: {} ({})", resource.name, dep_type);
271
272        let (type_str_plural, type_str_singular, _key_name, sanitized_key) =
273            get_key_names(resource, dep_type);
274
275        // Extract content from source file FIRST (before creating the struct)
276        // Declared dependencies should be rendered with their own context before being made available
277        // Non-dependencies just get raw content extraction (to avoid circular dependency issues)
278        let raw_content = extractor.extract_content(resource).await;
279
280        // Check if the dependency should be rendered
281        // Only render if this is a declared dependency AND content has template syntax
282        let should_render = if *is_dependency {
283            if let Some(content) = &raw_content {
284                // Don't render if content has literal guards (from templating: false)
285                if content.contains(NON_TEMPLATED_LITERAL_GUARD_START) {
286                    false
287                } else {
288                    // Only render if the content has template syntax
289                    content_contains_template_syntax(content)
290                }
291            } else {
292                false
293            }
294        } else {
295            // Not a declared dependency - don't render to avoid circular deps
296            false
297        };
298
299        // Compute the final content (either rendered, cached, or raw)
300        let final_content: String = if should_render {
301            // Build cache key to check if we've already rendered this exact resource
302            // CRITICAL: Include tool and resolved_commit in cache key to prevent cache pollution!
303            // Same path renders differently for different tools (claude-code vs opencode)
304            // and different commits must have different cache entries.
305            let cache_key = RenderCacheKey::new(
306                resource.path.clone(),
307                *dep_type,
308                resource.tool.clone(),
309                resource.variant_inputs.hash().to_string(),
310                resource.resolved_commit.clone(),
311            );
312
313            // Check cache first (ensure guard is dropped before any awaits)
314            let cache_result = extractor
315                .render_cache()
316                .lock()
317                .map_err(|e| {
318                    anyhow::anyhow!(
319                        "Render cache lock poisoned for resource '{}': {}. \
320                     This indicates a panic occurred while holding the lock.",
321                        resource.name,
322                        e
323                    )
324                })?
325                .get(&cache_key)
326                .cloned(); // MutexGuard dropped here
327
328            if let Some(cached_content) = cache_result {
329                tracing::debug!("Render cache hit for '{}' ({})", resource.name, dep_type);
330                cached_content
331            } else {
332                // Cache miss - need to render
333                tracing::debug!(
334                    "Render cache miss for '{}' ({}), rendering...",
335                    resource.name,
336                    dep_type
337                );
338
339                // Check if we're already rendering this dependency (cycle detection)
340                let dep_id = create_dependency_ref_string(
341                    resource.source.clone(),
342                    *dep_type,
343                    resource.name.clone(),
344                    resource.version.clone(),
345                );
346                if rendering_stack.contains(&dep_id) {
347                    let chain: Vec<String> = rendering_stack.iter().cloned().collect();
348                    anyhow::bail!(
349                        "Circular dependency detected while rendering '{}'. \
350                            Dependency chain: {} -> {}",
351                        resource.name,
352                        chain.join(" -> "),
353                        dep_id
354                    );
355                }
356
357                // Add to rendering stack
358                rendering_stack.insert(dep_id.clone());
359
360                // Build a template context for this dependency so it can be rendered with its own dependencies
361                let dep_resource_id = ResourceId::from_resource(resource);
362                let render_result = Box::pin(extractor.build_context_with_visited(
363                    &dep_resource_id,
364                    resource.variant_inputs.json(),
365                    rendering_stack,
366                ))
367                .await;
368
369                // Remove from stack after rendering (whether success or failure)
370                rendering_stack.remove(&dep_id);
371
372                match render_result {
373                    Ok(dep_context) => {
374                        // Render the dependency's content
375                        if let Some(content) = raw_content {
376                            let mut renderer = TemplateRenderer::new(
377                                    true,
378                                    extractor.project_dir().clone(),
379                                    None,
380                                )
381                                .with_context(|| {
382                                    format!(
383                                        "Failed to create template renderer for dependency '{}' (type: {:?})",
384                                        resource.name,
385                                        dep_type
386                                    )
387                                })?;
388
389                            // Create metadata for dependency rendering with basic chain info
390                            let metadata = crate::templating::renderer::RenderingMetadata {
391                                resource_name: resource.name.clone(),
392                                resource_type: *dep_type,
393                                dependency_chain: vec![], // TODO: Build full dependency chain
394                                source_path: None,
395                                depth: rendering_stack.len(),
396                            };
397
398                            let rendered = renderer
399                                    .render_template(&content, &dep_context, Some(&metadata))
400                                    .with_context(|| {
401                                        format!(
402                                            "Failed to render dependency '{}' (type: {:?}). \
403                                            This is a HARD FAILURE - dependency content MUST render successfully.\n\
404                                            Resource: {} (source: {}, path: {})",
405                                            resource.name,
406                                            dep_type,
407                                            resource.name,
408                                            resource.source.as_deref().unwrap_or("local"),
409                                            resource.path
410                                        )
411                                    })?;
412
413                            tracing::debug!(
414                                "Successfully rendered dependency content for '{}'",
415                                resource.name
416                            );
417
418                            // Store in cache for future use
419                            if let Ok(mut cache) = extractor.render_cache().lock() {
420                                cache.insert(cache_key.clone(), rendered.clone());
421                                tracing::debug!(
422                                    "Stored rendered content in cache for '{}'",
423                                    resource.name
424                                );
425                            }
426
427                            rendered
428                        } else {
429                            // No content extracted - use empty string
430                            String::new()
431                        }
432                    }
433                    Err(e) => {
434                        // Hard failure - context building must succeed for dependency rendering
435                        return Err(e.context(format!(
436                                "Failed to build template context for dependency '{}' (type: {:?}). \
437                                This is a HARD FAILURE - all dependencies must have valid contexts.\n\
438                                Resource: {} (source: {}, path: {})",
439                                resource.name,
440                                dep_type,
441                                resource.name,
442                                resource.source.as_deref().unwrap_or("local"),
443                                resource.path
444                            )));
445                    }
446                }
447            }
448        } else {
449            // No rendering needed, use raw content (guards will be collapsed after parent renders)
450            raw_content.unwrap_or_default()
451        };
452
453        // Create DependencyData with all fields including content
454        let dependency_data = DependencyData {
455            resource_type: type_str_singular,
456            name: resource.name.clone(),
457            install_path: to_native_path_display(&resource.installed_at),
458            source: resource.source.clone(),
459            version: resource.version.clone(),
460            resolved_commit: resource.resolved_commit.clone(),
461            checksum: resource.checksum.clone(),
462            path: resource.path.clone(),
463            content: final_content,
464        };
465
466        // Insert into the nested structure
467        let type_deps: &mut BTreeMap<String, DependencyData> =
468            deps.entry(type_str_plural.clone()).or_insert_with(BTreeMap::new);
469        type_deps.insert(sanitized_key.clone(), dependency_data);
470
471        tracing::debug!(
472            "  Added resource: {}[{}] -> {}",
473            type_str_plural,
474            sanitized_key,
475            resource.path
476        );
477    }
478
479    // Add custom alias mappings for the current resource's direct dependencies only.
480    // Each dependency will be rendered with its own context containing its own custom names.
481    tracing::debug!(
482        "Extracting custom dependency names for direct deps of: '{}'",
483        current_resource.name
484    );
485
486    // Process only the current resource's custom names (for its direct dependencies)
487    let current_custom_names =
488        extractor.extract_dependency_custom_names(current_resource).await.with_context(|| {
489            format!(
490                "Failed to extract custom dependency names from resource '{}' (type: {:?})",
491                current_resource.name, current_resource.resource_type
492            )
493        })?;
494    tracing::debug!(
495        "Extracted {} custom names from current resource '{}' (type: {:?})",
496        current_custom_names.len(),
497        current_resource.name,
498        current_resource.resource_type
499    );
500    if !current_custom_names.is_empty() || current_resource.name.contains("golang") {
501        tracing::info!(
502            "Extracted {} custom names from current resource '{}' (type: {:?})",
503            current_custom_names.len(),
504            current_resource.name,
505            current_resource.resource_type
506        );
507        for (dep_ref, custom_name) in &current_custom_names {
508            tracing::info!("  Will add alias: '{}' -> '{}'", dep_ref, custom_name);
509        }
510    }
511    for (dep_ref, custom_name) in current_custom_names {
512        add_custom_alias(&mut deps, &dep_ref, &custom_name);
513    }
514
515    // Debug: Print what we built
516    tracing::debug!(
517        "Built dependencies data with {} resource types for '{}'",
518        deps.len(),
519        current_resource.name
520    );
521    for (resource_type, resources) in &deps {
522        tracing::debug!("  Type {}: {} resources", resource_type, resources.len());
523        if resource_type == "snippets" {
524            for (key, data) in resources {
525                tracing::debug!("    - key='{}', name='{}', path='{}'", key, data.name, data.path);
526            }
527        } else {
528            for name in resources.keys() {
529                tracing::debug!("    - {}", name);
530            }
531        }
532    }
533
534    Ok(deps)
535}
536
537/// Helper function to add a custom name alias to the dependencies map.
538///
539/// This function searches for an already-processed resource in the `deps` map and creates
540/// an alias entry with the custom name. The resource should have already been added to
541/// `deps` with its path-based key during the main processing loop.
542///
543/// Note: This function doesn't need to do lockfile lookups with ResourceId because it
544/// searches within the already-built `deps` map. The deps map was built from the lockfile
545/// with all the correct template_vars and content.
546pub(crate) fn add_custom_alias(
547    deps: &mut BTreeMap<String, BTreeMap<String, DependencyData>>,
548    dep_ref: &str,
549    custom_name: &str,
550) {
551    // Parse dependency reference using centralized LockfileDependencyRef logic
552    let dep_ref_parsed = match LockfileDependencyRef::from_str(dep_ref) {
553        Ok(dep_ref) => dep_ref,
554        Err(e) => {
555            tracing::debug!(
556                "Skipping invalid dep_ref format '{}' for custom name '{}': {}",
557                dep_ref,
558                custom_name,
559                e
560            );
561            return;
562        }
563    };
564
565    let dep_type = dep_ref_parsed.resource_type;
566    let dep_name = &dep_ref_parsed.path;
567
568    let type_str_plural = dep_type.to_plural().to_string();
569
570    // Search for the resource in the deps map (already populated from lockfile)
571    if let Some(type_deps) = deps.get_mut(&type_str_plural) {
572        // Build name → key index for O(1) lookup instead of O(N²) linear search
573        let name_to_key: HashMap<String, String> = type_deps
574            .iter()
575            .flat_map(|(key, data)| {
576                // Map both the full name and various fallback names to the key
577                let mut mappings = vec![(data.name.clone(), key.clone())];
578
579                // Add basename fallbacks for direct manifest deps
580                if let Some(basename) = Path::new(&data.name).file_name().and_then(|n| n.to_str()) {
581                    mappings.push((basename.to_string(), key.clone()));
582                }
583                if let Some(stem) = Path::new(&data.path).file_stem().and_then(|n| n.to_str()) {
584                    mappings.push((stem.to_string(), key.clone()));
585                }
586                if let Some(path_basename) =
587                    Path::new(&data.path).file_name().and_then(|n| n.to_str())
588                {
589                    mappings.push((path_basename.to_string(), key.clone()));
590                }
591
592                mappings
593            })
594            .collect();
595
596        // Find the resource by name using O(1) lookup
597        let existing_data =
598            name_to_key.get(dep_name).and_then(|key| type_deps.get(key).cloned()).or_else(|| {
599                // Some direct manifest dependencies use the bare manifest key (no type prefix)
600                // even though transitive refs include the source-relative path (snippets/foo/bar).
601                // Fall back to matching by the last path segment to align the two representations.
602                Path::new(dep_name)
603                    .file_name()
604                    .and_then(|name| name.to_str())
605                    .and_then(|basename| name_to_key.get(basename))
606                    .and_then(|key| type_deps.get(key).cloned())
607            });
608
609        if let Some(data) = existing_data {
610            // Sanitize the alias (replace hyphens with underscores for Tera)
611            let sanitized_alias = custom_name.replace('-', "_");
612
613            tracing::info!(
614                "āœ“ Added {} alias '{}' -> resource '{}' (path: {})",
615                type_str_plural,
616                sanitized_alias,
617                dep_name,
618                data.path
619            );
620
621            // Add an alias entry pointing to the same data
622            type_deps.insert(sanitized_alias.clone(), data);
623        } else {
624            tracing::error!(
625                "āŒ NOT FOUND: {} resource '{}' for alias '{}'.\n  \
626                Dep ref: '{}'\n  \
627                Available {} (first 5): {}",
628                type_str_plural,
629                dep_name,
630                custom_name,
631                dep_ref,
632                type_deps.len(),
633                type_deps
634                    .iter()
635                    .take(5)
636                    .map(|(k, v)| format!("'{}' (name='{}')", k, v.name))
637                    .collect::<Vec<_>>()
638                    .join(", ")
639            );
640        }
641    } else {
642        tracing::debug!(
643            "Resource type '{}' not found in deps map when adding custom alias '{}' for '{}'",
644            type_str_plural,
645            custom_name,
646            dep_ref
647        );
648    }
649}