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