agpm_cli/templating/
dependencies.rs

1//! Dependency handling for template context building.
2//!
3//! This module provides functionality for extracting dependency information,
4//! custom names, and building the dependency data structure for template rendering.
5
6use anyhow::{Context as _, Result};
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use crate::core::ResourceType;
13use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
14use crate::lockfile::{LockFile, LockedResource, ResourceId};
15
16use super::cache::{RenderCache, RenderCacheKey};
17use super::content::{
18    ContentExtractor, NON_TEMPLATED_LITERAL_GUARD_START, content_contains_template_syntax,
19};
20use super::context::DependencyData;
21use super::renderer::TemplateRenderer;
22use super::utils::to_native_path_display;
23
24/// Helper function to create a LockfileDependencyRef string from a resource.
25///
26/// This centralizes the logic for creating dependency references based on whether
27/// the resource has a source (Git) or is local.
28fn create_dependency_ref_string(
29    source: Option<String>,
30    resource_type: ResourceType,
31    name: String,
32    version: Option<String>,
33) -> String {
34    if let Some(source) = source {
35        LockfileDependencyRef::git(source, resource_type, name, version).to_string()
36    } else {
37        LockfileDependencyRef::local(resource_type, name, version).to_string()
38    }
39}
40
41/// Trait for dependency extraction methods on TemplateContextBuilder.
42pub(crate) trait DependencyExtractor: ContentExtractor {
43    /// Get the lockfile
44    fn lockfile(&self) -> &Arc<LockFile>;
45
46    /// Get the render cache
47    fn render_cache(&self) -> &Arc<std::sync::Mutex<RenderCache>>;
48
49    /// Get the custom names cache
50    fn custom_names_cache(
51        &self,
52    ) -> &Arc<std::sync::Mutex<HashMap<String, BTreeMap<String, String>>>>;
53
54    /// Get the dependency specs cache
55    fn dependency_specs_cache(
56        &self,
57    ) -> &Arc<std::sync::Mutex<HashMap<String, BTreeMap<String, crate::manifest::DependencySpec>>>>;
58
59    /// Extract custom dependency names from a resource's frontmatter.
60    ///
61    /// Parses the resource file to extract the `dependencies` declaration with `name:` fields
62    /// and maps dependency references to their custom names.
63    ///
64    /// # Returns
65    ///
66    /// A BTreeMap mapping dependency references (e.g., "snippet/rust-best-practices") to custom
67    /// names (e.g., "best_practices") as declared in the resource's YAML frontmatter.
68    /// BTreeMap ensures deterministic iteration order for consistent context checksums.
69    async fn extract_dependency_custom_names(
70        &self,
71        resource: &LockedResource,
72    ) -> BTreeMap<String, String> {
73        // Build cache key from resource name and type
74        let cache_key = format!("{}@{:?}", resource.name, resource.resource_type);
75
76        // Check cache first
77        if let Ok(cache) = self.custom_names_cache().lock() {
78            if let Some(cached_names) = cache.get(&cache_key) {
79                tracing::debug!(
80                    "Custom names cache HIT for '{}' ({} names)",
81                    resource.name,
82                    cached_names.len()
83                );
84                return cached_names.clone();
85            }
86        }
87
88        tracing::debug!("Custom names cache MISS for '{}'", resource.name);
89
90        let mut custom_names = BTreeMap::new();
91
92        // Build a lookup structure upfront to avoid O(n³) nested loops
93        // Map: type -> Vec<(basename, full_dep_ref)>
94        // Use BTreeMap for deterministic iteration order
95        let mut lockfile_lookup: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
96
97        // Use parsed_dependencies() helper to parse all dependencies
98        for dep_ref in resource.parsed_dependencies() {
99            let lockfile_type = dep_ref.resource_type.to_string();
100            let lockfile_name = &dep_ref.path;
101            let lockfile_dep_ref = dep_ref.to_string();
102
103            // Extract basename from lockfile name
104            let lockfile_basename = std::path::Path::new(lockfile_name)
105                .file_stem()
106                .and_then(|s| s.to_str())
107                .unwrap_or(lockfile_name)
108                .to_string();
109
110            lockfile_lookup
111                .entry(lockfile_type)
112                .or_default()
113                .push((lockfile_basename, lockfile_dep_ref));
114        }
115
116        // Determine source path (same logic as extract_content)
117        let source_path = if let Some(_source_name) = &resource.source {
118            // Has source - check if local or Git
119            let url = match resource.url.as_ref() {
120                Some(u) => u,
121                None => return custom_names,
122            };
123
124            let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
125
126            if is_local_source {
127                // Local source
128                std::path::PathBuf::from(url).join(&resource.path)
129            } else {
130                // Git source
131                let sha = match resource.resolved_commit.as_deref() {
132                    Some(s) => s,
133                    None => return custom_names,
134                };
135                match self.cache().get_worktree_path(url, sha) {
136                    Ok(worktree_dir) => worktree_dir.join(&resource.path),
137                    Err(_) => return custom_names,
138                }
139            }
140        } else {
141            // Local file
142            let local_path = std::path::Path::new(&resource.path);
143            if local_path.is_absolute() {
144                local_path.to_path_buf()
145            } else {
146                self.project_dir().join(local_path)
147            }
148        };
149
150        // Read and parse the file based on type
151        if resource.path.ends_with(".md") {
152            // Parse markdown frontmatter with template rendering
153            if let Ok(content) = tokio::fs::read_to_string(&source_path).await {
154                // Use templated parsing to handle conditional blocks ({% if %}) in frontmatter
155                if let Ok(doc) = crate::markdown::MarkdownDocument::parse_with_templating(
156                    &content,
157                    Some(resource.variant_inputs.json()),
158                    Some(&source_path),
159                ) {
160                    // Extract dependencies from parsed metadata
161                    if let Some(markdown_metadata) = &doc.metadata {
162                        // Convert MarkdownMetadata to DependencyMetadata
163                        // Merge both root-level dependencies and agpm.dependencies
164                        let dependency_metadata = crate::manifest::DependencyMetadata::new(
165                            markdown_metadata.dependencies.clone(),
166                            markdown_metadata.get_agpm_metadata(),
167                        );
168
169                        if let Some(deps_map) = dependency_metadata.get_dependencies() {
170                            // Process each resource type (agents, snippets, commands, etc.)
171                            for (resource_type_str, deps_array) in deps_map {
172                                // Convert frontmatter type to lockfile type (singular)
173                                let lockfile_type: String = match resource_type_str.as_str() {
174                                    "agents" | "agent" => "agent".to_string(),
175                                    "snippets" | "snippet" => "snippet".to_string(),
176                                    "commands" | "command" => "command".to_string(),
177                                    "scripts" | "script" => "script".to_string(),
178                                    "hooks" | "hook" => "hook".to_string(),
179                                    "mcp-servers" | "mcp-server" => "mcp-server".to_string(),
180                                    _ => continue, // Skip unknown types
181                                };
182
183                                // Get lockfile entries for this type only (O(1) lookup instead of O(n) iteration)
184                                let type_entries = match lockfile_lookup.get(&lockfile_type) {
185                                    Some(entries) => entries,
186                                    None => continue, // No lockfile deps of this type
187                                };
188
189                                // deps_array is Vec<DependencySpec>
190                                for dep_spec in deps_array {
191                                    let path = &dep_spec.path;
192                                    if let Some(custom_name) = &dep_spec.name {
193                                        // Extract basename from the path (without extension)
194                                        let basename = std::path::Path::new(path)
195                                            .file_stem()
196                                            .and_then(|s| s.to_str())
197                                            .unwrap_or(path);
198
199                                        tracing::info!(
200                                            "Found custom name '{}' for path '{}' (basename: '{}')",
201                                            custom_name,
202                                            path,
203                                            basename
204                                        );
205
206                                        // Check if basename has template variables
207                                        if basename.contains("{{") {
208                                            // Template variable in basename - try suffix matching
209                                            // e.g., "{{ agpm.project.language }}-best-practices" -> "-best-practices"
210                                            if let Some(static_suffix_start) = basename.find("}}") {
211                                                let static_suffix =
212                                                    &basename[static_suffix_start + 2..];
213
214                                                // Search for any lockfile basename ending with this suffix
215                                                for (lockfile_basename, lockfile_dep_ref) in
216                                                    type_entries
217                                                {
218                                                    if lockfile_basename.ends_with(static_suffix) {
219                                                        custom_names.insert(
220                                                            lockfile_dep_ref.clone(),
221                                                            custom_name.to_string(),
222                                                        );
223                                                    }
224                                                }
225                                            }
226                                        } else {
227                                            // No template variables - exact basename match (O(n) but only within type)
228                                            for (lockfile_basename, lockfile_dep_ref) in
229                                                type_entries
230                                            {
231                                                if lockfile_basename == basename {
232                                                    custom_names.insert(
233                                                        lockfile_dep_ref.clone(),
234                                                        custom_name.to_string(),
235                                                    );
236                                                    break; // Found exact match, no need to continue
237                                                }
238                                            }
239                                        }
240                                    }
241                                }
242                            }
243                        }
244                    }
245                }
246            }
247        } else if resource.path.ends_with(".json") {
248            // Parse JSON dependencies field with template rendering
249            if let Ok(content) = tokio::fs::read_to_string(&source_path).await {
250                // Apply templating to JSON content to handle conditional blocks
251                let mut parser = crate::markdown::frontmatter::FrontmatterParser::new();
252                let templated_content = parser
253                    .apply_templating(&content, Some(resource.variant_inputs.json()), &source_path)
254                    .unwrap_or_else(|_| content.clone());
255
256                // Parse JSON and extract dependencies field
257                if let Ok(json_value) =
258                    serde_json::from_str::<serde_json::Value>(&templated_content)
259                {
260                    // Extract both root-level dependencies and agpm.dependencies
261                    let root_deps = json_value.get("dependencies").and_then(|v| {
262                        serde_json::from_value::<
263                            BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
264                        >(v.clone())
265                        .ok()
266                    });
267
268                    let agpm_metadata = json_value.get("agpm").and_then(|v| {
269                        serde_json::from_value::<crate::manifest::dependency_spec::AgpmMetadata>(
270                            v.clone(),
271                        )
272                        .ok()
273                    });
274
275                    // Merge both dependency sources
276                    let dependency_metadata =
277                        crate::manifest::DependencyMetadata::new(root_deps, agpm_metadata);
278
279                    if let Some(deps_map) = dependency_metadata.get_dependencies() {
280                        // Process each resource type (agents, snippets, commands, etc.)
281                        for (resource_type_str, deps_array) in deps_map {
282                            // Convert frontmatter type to lockfile type (singular)
283                            let lockfile_type: String = match resource_type_str.as_str() {
284                                "agents" | "agent" => "agent".to_string(),
285                                "snippets" | "snippet" => "snippet".to_string(),
286                                "commands" | "command" => "command".to_string(),
287                                "scripts" | "script" => "script".to_string(),
288                                "hooks" | "hook" => "hook".to_string(),
289                                "mcp-servers" | "mcp-server" => "mcp-server".to_string(),
290                                _ => continue, // Skip unknown types
291                            };
292
293                            // Get lockfile entries for this type only (O(1) lookup instead of O(n) iteration)
294                            let type_entries = match lockfile_lookup.get(&lockfile_type) {
295                                Some(entries) => entries,
296                                None => continue, // No lockfile deps of this type
297                            };
298
299                            // deps_array is Vec<DependencySpec>
300                            for dep_spec in deps_array {
301                                let path = &dep_spec.path;
302                                if let Some(custom_name) = &dep_spec.name {
303                                    // Extract basename from the path (without extension)
304                                    let basename = std::path::Path::new(path)
305                                        .file_stem()
306                                        .and_then(|s| s.to_str())
307                                        .unwrap_or(path);
308
309                                    tracing::info!(
310                                        "Found custom name '{}' for path '{}' (basename: '{}') from JSON",
311                                        custom_name,
312                                        path,
313                                        basename
314                                    );
315
316                                    // Check if basename has template variables
317                                    if basename.contains("{{") {
318                                        // Template variable in basename - try suffix matching
319                                        // e.g., "{{ agpm.project.language }}-best-practices" -> "-best-practices"
320                                        if let Some(static_suffix_start) = basename.find("}}") {
321                                            let static_suffix =
322                                                &basename[static_suffix_start + 2..];
323
324                                            // Search for any lockfile basename ending with this suffix
325                                            for (lockfile_basename, lockfile_dep_ref) in
326                                                type_entries
327                                            {
328                                                if lockfile_basename.ends_with(static_suffix) {
329                                                    custom_names.insert(
330                                                        lockfile_dep_ref.clone(),
331                                                        custom_name.to_string(),
332                                                    );
333                                                }
334                                            }
335                                        }
336                                    } else {
337                                        // No template variables - exact basename match (O(n) but only within type)
338                                        for (lockfile_basename, lockfile_dep_ref) in type_entries {
339                                            if lockfile_basename == basename {
340                                                custom_names.insert(
341                                                    lockfile_dep_ref.clone(),
342                                                    custom_name.to_string(),
343                                                );
344                                                break; // Found exact match, no need to continue
345                                            }
346                                        }
347                                    }
348                                }
349                            }
350                        }
351                    }
352                }
353            }
354        }
355
356        // Store in cache before returning
357        if let Ok(mut cache) = self.custom_names_cache().lock() {
358            cache.insert(cache_key, custom_names.clone());
359            tracing::debug!(
360                "Stored {} custom names in cache for '{}'",
361                custom_names.len(),
362                resource.name
363            );
364        }
365
366        custom_names
367    }
368
369    /// Extract full dependency specifications from a resource's frontmatter.
370    ///
371    /// Parses the resource file to extract complete DependencySpec objects including
372    /// tool, name, flatten, and install fields. This information is used to build
373    /// complete ResourceIds for dependency lookups.
374    ///
375    /// # Returns
376    ///
377    /// A BTreeMap mapping dependency references (e.g., "snippet:snippets/commands/commit")
378    /// to their full DependencySpec objects. BTreeMap ensures deterministic iteration.
379    async fn extract_dependency_specs(
380        &self,
381        resource: &LockedResource,
382    ) -> BTreeMap<String, crate::manifest::DependencySpec> {
383        // Build cache key from resource name and type
384        let cache_key = format!("{}@{:?}", resource.name, resource.resource_type);
385
386        // Check cache first
387        if let Ok(cache) = self.dependency_specs_cache().lock() {
388            if let Some(cached_specs) = cache.get(&cache_key) {
389                tracing::debug!(
390                    "Dependency specs cache HIT for '{}' ({} specs)",
391                    resource.name,
392                    cached_specs.len()
393                );
394                return cached_specs.clone();
395            }
396        }
397
398        tracing::debug!("Dependency specs cache MISS for '{}'", resource.name);
399
400        let mut dependency_specs = BTreeMap::new();
401
402        // Determine source path (same logic as extract_content)
403        let source_path = if let Some(_source_name) = &resource.source {
404            // Has source - check if local or Git
405            let url = match resource.url.as_ref() {
406                Some(u) => u,
407                None => return dependency_specs,
408            };
409
410            let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
411
412            if is_local_source {
413                // Local source
414                std::path::PathBuf::from(url).join(&resource.path)
415            } else {
416                // Git source
417                let sha = match resource.resolved_commit.as_deref() {
418                    Some(s) => s,
419                    None => return dependency_specs,
420                };
421                match self.cache().get_worktree_path(url, sha) {
422                    Ok(worktree_dir) => worktree_dir.join(&resource.path),
423                    Err(_) => return dependency_specs,
424                }
425            }
426        } else {
427            // Local file
428            let local_path = std::path::Path::new(&resource.path);
429            if local_path.is_absolute() {
430                local_path.to_path_buf()
431            } else {
432                self.project_dir().join(local_path)
433            }
434        };
435
436        // Read and parse the file based on type
437        if resource.path.ends_with(".md") {
438            // Parse markdown frontmatter with template rendering
439            if let Ok(content) = tokio::fs::read_to_string(&source_path).await {
440                // Use templated parsing to handle conditional blocks ({% if %}) in frontmatter
441                if let Ok(doc) = crate::markdown::MarkdownDocument::parse_with_templating(
442                    &content,
443                    Some(resource.variant_inputs.json()),
444                    Some(&source_path),
445                ) {
446                    // Extract dependencies from parsed metadata
447                    if let Some(markdown_metadata) = &doc.metadata {
448                        // Convert MarkdownMetadata to DependencyMetadata
449                        let dependency_metadata = crate::manifest::DependencyMetadata::new(
450                            markdown_metadata.dependencies.clone(),
451                            markdown_metadata.get_agpm_metadata(),
452                        );
453
454                        if let Some(deps_map) = dependency_metadata.get_dependencies() {
455                            // Process each resource type
456                            for (resource_type_str, deps_array) in deps_map {
457                                // Convert frontmatter type to ResourceType
458                                let resource_type = match resource_type_str.as_str() {
459                                    "agents" | "agent" => crate::core::ResourceType::Agent,
460                                    "snippets" | "snippet" => crate::core::ResourceType::Snippet,
461                                    "commands" | "command" => crate::core::ResourceType::Command,
462                                    "scripts" | "script" => crate::core::ResourceType::Script,
463                                    "hooks" | "hook" => crate::core::ResourceType::Hook,
464                                    "mcp-servers" | "mcp-server" => {
465                                        crate::core::ResourceType::McpServer
466                                    }
467                                    _ => continue,
468                                };
469
470                                // Store each DependencySpec with its lockfile reference as key
471                                for dep_spec in deps_array {
472                                    // Canonicalize the frontmatter path to match lockfile format
473                                    // Frontmatter paths are relative to the resource file itself
474                                    // We need to resolve them relative to source root (not filesystem paths!)
475                                    let canonical_path = if dep_spec.path.starts_with("../")
476                                        || dep_spec.path.starts_with("./")
477                                    {
478                                        // Relative path - resolve using source-relative paths, not filesystem paths
479                                        // Get the parent directory of the resource within the source
480                                        let resource_parent = std::path::Path::new(&resource.path)
481                                            .parent()
482                                            .unwrap_or_else(|| std::path::Path::new(""));
483
484                                        // Join with the relative dependency path (still may have ..)
485                                        let joined = resource_parent.join(&dep_spec.path);
486
487                                        // Normalize to remove .. and . components, then format for storage
488                                        let normalized = crate::utils::normalize_path(&joined);
489                                        crate::utils::normalize_path_for_storage(&normalized)
490                                    } else {
491                                        // Absolute or already canonical
492                                        dep_spec.path.clone()
493                                    };
494
495                                    // Remove extension to match lockfile format
496                                    let normalized_path = std::path::Path::new(&canonical_path)
497                                        .with_extension("")
498                                        .to_string_lossy()
499                                        .to_string();
500
501                                    // Build the dependency reference string
502                                    let dep_ref = if let Some(ref src) = resource.source {
503                                        LockfileDependencyRef::git(
504                                            src.clone(),
505                                            resource_type,
506                                            normalized_path,
507                                            resource.version.clone(),
508                                        )
509                                        .to_string()
510                                    } else {
511                                        LockfileDependencyRef::local(
512                                            resource_type,
513                                            normalized_path,
514                                            resource.version.clone(),
515                                        )
516                                        .to_string()
517                                    };
518
519                                    dependency_specs.insert(dep_ref, dep_spec.clone());
520                                }
521                            }
522                        }
523                    }
524                }
525            }
526        } else if resource.path.ends_with(".json") {
527            // Parse JSON dependencies field with template rendering
528            if let Ok(content) = tokio::fs::read_to_string(&source_path).await {
529                // Apply templating to JSON content to handle conditional blocks
530                let mut parser = crate::markdown::frontmatter::FrontmatterParser::new();
531                let templated_content = parser
532                    .apply_templating(&content, Some(resource.variant_inputs.json()), &source_path)
533                    .unwrap_or_else(|_| content.clone());
534
535                if let Ok(json_value) =
536                    serde_json::from_str::<serde_json::Value>(&templated_content)
537                {
538                    // Extract both root-level dependencies and agpm.dependencies
539                    let root_deps = json_value.get("dependencies").and_then(|v| {
540                        serde_json::from_value::<
541                            BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
542                        >(v.clone())
543                        .ok()
544                    });
545
546                    let agpm_metadata = json_value.get("agpm").and_then(|v| {
547                        serde_json::from_value::<crate::manifest::dependency_spec::AgpmMetadata>(
548                            v.clone(),
549                        )
550                        .ok()
551                    });
552
553                    // Merge both dependency sources
554                    let dependency_metadata =
555                        crate::manifest::DependencyMetadata::new(root_deps, agpm_metadata);
556
557                    if let Some(deps_map) = dependency_metadata.get_dependencies() {
558                        // Process each resource type
559                        for (resource_type_str, deps_array) in deps_map {
560                            // Convert frontmatter type to ResourceType
561                            let resource_type = match resource_type_str.as_str() {
562                                "agents" | "agent" => crate::core::ResourceType::Agent,
563                                "snippets" | "snippet" => crate::core::ResourceType::Snippet,
564                                "commands" | "command" => crate::core::ResourceType::Command,
565                                "scripts" | "script" => crate::core::ResourceType::Script,
566                                "hooks" | "hook" => crate::core::ResourceType::Hook,
567                                "mcp-servers" | "mcp-server" => {
568                                    crate::core::ResourceType::McpServer
569                                }
570                                _ => continue,
571                            };
572
573                            // Store each DependencySpec with its lockfile reference as key
574                            for dep_spec in deps_array {
575                                // Canonicalize the frontmatter path to match lockfile format
576                                // Frontmatter paths are relative to the resource file itself
577                                // We need to resolve them relative to source root (not filesystem paths!)
578                                let canonical_path = if dep_spec.path.starts_with("../")
579                                    || dep_spec.path.starts_with("./")
580                                {
581                                    // Relative path - resolve using source-relative paths, not filesystem paths
582                                    // Get the parent directory of the resource within the source
583                                    let resource_parent = std::path::Path::new(&resource.path)
584                                        .parent()
585                                        .unwrap_or_else(|| std::path::Path::new(""));
586
587                                    // Join with the relative dependency path (still may have ..)
588                                    let joined = resource_parent.join(&dep_spec.path);
589
590                                    // Normalize to remove .. and . components, then format for storage
591                                    let normalized = crate::utils::normalize_path(&joined);
592                                    crate::utils::normalize_path_for_storage(&normalized)
593                                } else {
594                                    // Absolute or already canonical
595                                    dep_spec.path.clone()
596                                };
597
598                                // Remove extension to match lockfile format
599                                let normalized_path = std::path::Path::new(&canonical_path)
600                                    .with_extension("")
601                                    .to_string_lossy()
602                                    .to_string();
603
604                                // Build the dependency reference string
605                                let dep_ref = if let Some(ref src) = resource.source {
606                                    LockfileDependencyRef::git(
607                                        src.clone(),
608                                        resource_type,
609                                        normalized_path,
610                                        resource.version.clone(),
611                                    )
612                                    .to_string()
613                                } else {
614                                    LockfileDependencyRef::local(
615                                        resource_type,
616                                        normalized_path,
617                                        resource.version.clone(),
618                                    )
619                                    .to_string()
620                                };
621
622                                dependency_specs.insert(dep_ref, dep_spec.clone());
623                            }
624                        }
625                    }
626                }
627            }
628        }
629
630        // Store in cache before returning
631        if let Ok(mut cache) = self.dependency_specs_cache().lock() {
632            cache.insert(cache_key, dependency_specs.clone());
633            tracing::debug!(
634                "Stored {} dependency specs in cache for '{}'",
635                dependency_specs.len(),
636                resource.name
637            );
638        }
639
640        dependency_specs
641    }
642
643    /// Generate dependency name from a path (matching resolver logic).
644    ///
645    /// For local transitive dependencies, the resolver uses the full relative path
646    /// (without extension) as the resource name to maintain uniqueness.
647    #[allow(dead_code)]
648    fn generate_dependency_name_from_path(&self, path: &str) -> String {
649        // Strip file extension - this matches what the resolver stores as the name
650        path.strip_suffix(".md").or_else(|| path.strip_suffix(".json")).unwrap_or(path).to_string()
651    }
652
653    /// Build dependency data for the template context.
654    ///
655    /// This creates a nested structure containing:
656    /// 1. ALL resources from the lockfile (path-based names) - for universal access
657    /// 2. Current resource's declared dependencies (custom alias names) - for scoped access
658    ///
659    /// This dual approach ensures:
660    /// - Any resource can access any other resource via path-based names
661    /// - Resources can use custom aliases for their dependencies without collisions
662    ///
663    /// # Arguments
664    ///
665    /// * `current_resource` - The resource being rendered (for scoped alias mapping)
666    async fn build_dependencies_data(
667        &self,
668        current_resource: &LockedResource,
669        rendering_stack: &mut HashSet<String>,
670    ) -> Result<BTreeMap<String, BTreeMap<String, DependencyData>>> {
671        let mut deps = BTreeMap::new();
672
673        // Extract dependency specifications from current resource's frontmatter
674        // This provides tool, name, flatten, and install fields for each dependency
675        let dependency_specs = self.extract_dependency_specs(current_resource).await;
676
677        // Helper function to determine the key name for a resource
678        let get_key_names = |resource: &LockedResource,
679                             dep_type: &ResourceType|
680         -> (String, String, String, String) {
681            let type_str_plural = dep_type.to_plural().to_string();
682            let type_str_singular = dep_type.to_string();
683
684            // Determine the key to use for universal access in the template context
685            // DO NOT use manifest_alias - it's only for pattern aliases from manifest,
686            // not transitive custom names which are extracted during template rendering
687            let key_name = if resource.name.contains('/') || resource.name.contains('\\') {
688                // Name looks like a path - extract basename without extension
689                std::path::Path::new(&resource.name)
690                    .file_stem()
691                    .and_then(|s| s.to_str())
692                    .unwrap_or(&resource.name)
693                    .to_string()
694            } else {
695                // Use name as-is
696                resource.name.clone()
697            };
698
699            // Sanitize the key name by replacing hyphens with underscores
700            // to avoid Tera interpreting them as minus operators
701            let sanitized_key = key_name.replace('-', "_");
702
703            (type_str_plural, type_str_singular, key_name, sanitized_key)
704        };
705
706        // Collect ONLY direct dependencies (not transitive!)
707        // Each dependency will be rendered with its own context containing its own direct deps.
708        let mut resources_to_process: Vec<(&LockedResource, ResourceType, bool)> = Vec::new();
709        let mut visited_dep_ids = HashSet::new();
710
711        for dep_ref in current_resource.parsed_dependencies() {
712            // Build dep_id for deduplication tracking
713            let dep_id = dep_ref.to_string();
714
715            // Skip if we've already processed this dependency
716            if !visited_dep_ids.insert(dep_id.clone()) {
717                continue;
718            }
719
720            let resource_type = dep_ref.resource_type;
721            let name = &dep_ref.path;
722
723            // Get the dependency spec for this reference (if declared in frontmatter)
724            // NOTE: dependency_specs keys are normalized (no ../ segments) because
725            // extract_dependency_specs normalizes paths using Path component iteration.
726            // We must normalize the lookup key to match.
727            let dep_spec = {
728                // Normalize the path to match what extract_dependency_specs stored
729                let normalized_path = {
730                    let path = std::path::Path::new(&dep_ref.path);
731                    let normalized = crate::utils::normalize_path(path);
732                    normalized.to_string_lossy().to_string()
733                };
734
735                // Create a normalized dep_ref for cache lookup only
736                let normalized_dep_ref = LockfileDependencyRef::new(
737                    dep_ref.source.clone(),
738                    dep_ref.resource_type,
739                    normalized_path,
740                    dep_ref.version.clone(),
741                );
742                let normalized_dep_id = normalized_dep_ref.to_string();
743
744                dependency_specs.get(&normalized_dep_id)
745            };
746
747            tracing::debug!(
748                "Looking up dep_spec for dep_id='{}', found={}, available_keys={:?}",
749                dep_id,
750                dep_spec.is_some(),
751                dependency_specs.keys().collect::<Vec<_>>()
752            );
753
754            // Determine the tool for this dependency
755            // Priority: explicit tool in DependencySpec > inherited from parent
756            let dep_tool =
757                dep_spec.and_then(|spec| spec.tool.as_ref()).or(current_resource.tool.as_ref());
758
759            // Determine the source for this dependency
760            // Use dep_ref.source if present, otherwise inherit from parent
761            let dep_source = dep_ref.source.as_ref().or(current_resource.source.as_ref());
762
763            // Build complete ResourceId for precise lookup
764            // Try parent's variant_inputs_hash first (for transitive deps that inherit context)
765            let dep_resource_id_with_parent_hash = ResourceId::new(
766                name.clone(),
767                dep_source.cloned(),
768                dep_tool.cloned(),
769                resource_type,
770                current_resource.variant_inputs.hash().to_string(),
771            );
772
773            tracing::debug!(
774                "[DEBUG] Template context looking up: name='{}', type={:?}, source={:?}, tool={:?}, hash={}",
775                name,
776                resource_type,
777                dep_source,
778                dep_tool,
779                &current_resource.variant_inputs.hash().to_string()[..8]
780            );
781
782            // Look up the dependency in the lockfile by full ResourceId
783            // Try with parent's hash first, then fall back to empty hash for direct manifest deps
784            let mut dep_resource =
785                self.lockfile().find_resource_by_id(&dep_resource_id_with_parent_hash);
786
787            // If not found with parent's hash, try with empty hash (direct manifest dependencies)
788            if dep_resource.is_none() {
789                let dep_resource_id_empty_hash = ResourceId::new(
790                    name.clone(),
791                    dep_source.cloned(),
792                    dep_tool.cloned(),
793                    resource_type,
794                    crate::resolver::lockfile_builder::VariantInputs::default().hash().to_string(),
795                );
796                dep_resource = self.lockfile().find_resource_by_id(&dep_resource_id_empty_hash);
797
798                if dep_resource.is_some() {
799                    tracing::debug!(
800                        "  [DIRECT MANIFEST DEP] Found dependency '{}' with empty variant_hash (direct manifest dependency)",
801                        name
802                    );
803                }
804            }
805
806            if let Some(dep_resource) = dep_resource {
807                // Add this dependency to resources to process (true = declared dependency)
808                resources_to_process.push((dep_resource, resource_type, true));
809
810                tracing::debug!(
811                    "  [DIRECT DEP] Found dependency '{}' (tool: {:?}) for '{}'",
812                    name,
813                    dep_tool,
814                    current_resource.name
815                );
816            } else {
817                tracing::warn!(
818                    "Dependency '{}' (type: {:?}, tool: {:?}) not found in lockfile for resource '{}'",
819                    name,
820                    resource_type,
821                    dep_tool,
822                    current_resource.name
823                );
824            }
825        }
826
827        tracing::debug!(
828            "Building dependencies data with {} direct dependencies for '{}'",
829            resources_to_process.len(),
830            current_resource.name
831        );
832
833        // CRITICAL: Sort resources_to_process for deterministic ordering!
834        // This ensures that even if resources were added in different orders,
835        // we process them in a consistent order, leading to deterministic context building.
836        // Sort by: (resource_type, name, is_dependency) for full determinism
837        resources_to_process.sort_by(|a, b| {
838            use std::cmp::Ordering;
839            // First by resource type
840            match a.1.cmp(&b.1) {
841                Ordering::Equal => {
842                    // Then by resource name
843                    match a.0.name.cmp(&b.0.name) {
844                        Ordering::Equal => {
845                            // Finally by is_dependency (dependencies first)
846                            b.2.cmp(&a.2) // Reverse to put true before false
847                        }
848                        other => other,
849                    }
850                }
851                other => other,
852            }
853        });
854
855        // Debug: log all resources being processed
856        for (resource, dep_type, is_dep) in &resources_to_process {
857            tracing::debug!(
858                "  [LOCKFILE] Resource: {} (type: {:?}, install: {:?}, is_dependency: {})",
859                resource.name,
860                dep_type,
861                resource.install,
862                is_dep
863            );
864        }
865
866        // Get current resource ID for filtering
867        let current_resource_id = create_dependency_ref_string(
868            current_resource.source.clone(),
869            current_resource.resource_type,
870            current_resource.name.clone(),
871            current_resource.version.clone(),
872        );
873
874        // Process each resource (excluding the current resource to prevent self-reference)
875        for (resource, dep_type, is_dependency) in &resources_to_process {
876            let resource_id = create_dependency_ref_string(
877                resource.source.clone(),
878                *dep_type,
879                resource.name.clone(),
880                resource.version.clone(),
881            );
882
883            // Skip if this is the current resource (prevent self-dependency)
884            if resource_id == current_resource_id {
885                tracing::debug!(
886                    "  Skipping current resource: {} (preventing self-reference)",
887                    resource.name
888                );
889                continue;
890            }
891
892            tracing::debug!("  Processing resource: {} ({})", resource.name, dep_type);
893
894            let (type_str_plural, type_str_singular, _key_name, sanitized_key) =
895                get_key_names(resource, dep_type);
896
897            // Extract content from source file FIRST (before creating the struct)
898            // Declared dependencies should be rendered with their own context before being made available
899            // Non-dependencies just get raw content extraction (to avoid circular dependency issues)
900            let raw_content = self.extract_content(resource).await;
901
902            // Check if the dependency should be rendered
903            // Only render if this is a declared dependency AND content has template syntax
904            let should_render = if *is_dependency {
905                if let Some(content) = &raw_content {
906                    // Don't render if content has literal guards (from templating: false)
907                    if content.contains(NON_TEMPLATED_LITERAL_GUARD_START) {
908                        false
909                    } else {
910                        // Only render if the content has template syntax
911                        content_contains_template_syntax(content)
912                    }
913                } else {
914                    false
915                }
916            } else {
917                // Not a declared dependency - don't render to avoid circular deps
918                false
919            };
920
921            // Compute the final content (either rendered, cached, or raw)
922            let final_content: String = if should_render {
923                // Build cache key to check if we've already rendered this exact resource
924                // CRITICAL: Include tool and resolved_commit in cache key to prevent cache pollution!
925                // Same path renders differently for different tools (claude-code vs opencode)
926                // and different commits must have different cache entries.
927                let cache_key = RenderCacheKey::new(
928                    resource.path.clone(),
929                    *dep_type,
930                    resource.tool.clone(),
931                    resource.variant_inputs.hash().to_string(),
932                    resource.resolved_commit.clone(),
933                );
934
935                // Check cache first (ensure guard is dropped before any awaits)
936                let cache_result = self
937                    .render_cache()
938                    .lock()
939                    .map_err(|e| {
940                        anyhow::anyhow!(
941                            "Render cache lock poisoned for resource '{}': {}. \
942                         This indicates a panic occurred while holding the lock.",
943                            resource.name,
944                            e
945                        )
946                    })?
947                    .get(&cache_key)
948                    .cloned(); // MutexGuard dropped here
949
950                if let Some(cached_content) = cache_result {
951                    tracing::debug!("Render cache hit for '{}' ({})", resource.name, dep_type);
952                    cached_content
953                } else {
954                    // Cache miss - need to render
955                    tracing::debug!(
956                        "Render cache miss for '{}' ({}), rendering...",
957                        resource.name,
958                        dep_type
959                    );
960
961                    // Check if we're already rendering this dependency (cycle detection)
962                    let dep_id = create_dependency_ref_string(
963                        resource.source.clone(),
964                        *dep_type,
965                        resource.name.clone(),
966                        resource.version.clone(),
967                    );
968                    if rendering_stack.contains(&dep_id) {
969                        let chain: Vec<String> = rendering_stack.iter().cloned().collect();
970                        anyhow::bail!(
971                            "Circular dependency detected while rendering '{}'. \
972                                Dependency chain: {} -> {}",
973                            resource.name,
974                            chain.join(" -> "),
975                            dep_id
976                        );
977                    }
978
979                    // Add to rendering stack
980                    rendering_stack.insert(dep_id.clone());
981
982                    // Build a template context for this dependency so it can be rendered with its own dependencies
983                    let dep_resource_id = ResourceId::from_resource(resource);
984                    let render_result = Box::pin(self.build_context_with_visited(
985                        &dep_resource_id,
986                        resource.variant_inputs.json(),
987                        rendering_stack,
988                    ))
989                    .await;
990
991                    // Remove from stack after rendering (whether success or failure)
992                    rendering_stack.remove(&dep_id);
993
994                    match render_result {
995                        Ok(dep_context) => {
996                            // Render the dependency's content
997                            if let Some(content) = raw_content {
998                                let mut renderer = TemplateRenderer::new(
999                                        true,
1000                                        self.project_dir().clone(),
1001                                        None,
1002                                    )
1003                                    .with_context(|| {
1004                                        format!(
1005                                            "Failed to create template renderer for dependency '{}' (type: {:?})",
1006                                            resource.name,
1007                                            dep_type
1008                                        )
1009                                    })?;
1010
1011                                let rendered = renderer
1012                                        .render_template(&content, &dep_context)
1013                                        .with_context(|| {
1014                                            format!(
1015                                                "Failed to render dependency '{}' (type: {:?}). \
1016                                                This is a HARD FAILURE - dependency content MUST render successfully.\n\
1017                                                Resource: {} (source: {}, path: {})",
1018                                                resource.name,
1019                                                dep_type,
1020                                                resource.name,
1021                                                resource.source.as_deref().unwrap_or("local"),
1022                                                resource.path
1023                                            )
1024                                        })?;
1025
1026                                tracing::debug!(
1027                                    "Successfully rendered dependency content for '{}'",
1028                                    resource.name
1029                                );
1030
1031                                // Store in cache for future use
1032                                if let Ok(mut cache) = self.render_cache().lock() {
1033                                    cache.insert(cache_key.clone(), rendered.clone());
1034                                    tracing::debug!(
1035                                        "Stored rendered content in cache for '{}'",
1036                                        resource.name
1037                                    );
1038                                }
1039
1040                                rendered
1041                            } else {
1042                                // No content extracted - use empty string
1043                                String::new()
1044                            }
1045                        }
1046                        Err(e) => {
1047                            // Hard failure - context building must succeed for dependency rendering
1048                            return Err(e.context(format!(
1049                                    "Failed to build template context for dependency '{}' (type: {:?}). \
1050                                    This is a HARD FAILURE - all dependencies must have valid contexts.\n\
1051                                    Resource: {} (source: {}, path: {})",
1052                                    resource.name,
1053                                    dep_type,
1054                                    resource.name,
1055                                    resource.source.as_deref().unwrap_or("local"),
1056                                    resource.path
1057                                )));
1058                        }
1059                    }
1060                }
1061            } else {
1062                // No rendering needed, use raw content (guards will be collapsed after parent renders)
1063                raw_content.unwrap_or_default()
1064            };
1065
1066            // Create DependencyData with all fields including content
1067            let dependency_data = DependencyData {
1068                resource_type: type_str_singular,
1069                name: resource.name.clone(),
1070                install_path: to_native_path_display(&resource.installed_at),
1071                source: resource.source.clone(),
1072                version: resource.version.clone(),
1073                resolved_commit: resource.resolved_commit.clone(),
1074                checksum: resource.checksum.clone(),
1075                path: resource.path.clone(),
1076                content: final_content,
1077            };
1078
1079            // Insert into the nested structure
1080            let type_deps: &mut BTreeMap<String, DependencyData> =
1081                deps.entry(type_str_plural.clone()).or_insert_with(BTreeMap::new);
1082            type_deps.insert(sanitized_key.clone(), dependency_data);
1083
1084            tracing::debug!(
1085                "  Added resource: {}[{}] -> {}",
1086                type_str_plural,
1087                sanitized_key,
1088                resource.path
1089            );
1090        }
1091
1092        // Add custom alias mappings for the current resource's direct dependencies only.
1093        // Each dependency will be rendered with its own context containing its own custom names.
1094        tracing::debug!(
1095            "Extracting custom dependency names for direct deps of: '{}'",
1096            current_resource.name
1097        );
1098
1099        // Process only the current resource's custom names (for its direct dependencies)
1100        let current_custom_names = self.extract_dependency_custom_names(current_resource).await;
1101        tracing::debug!(
1102            "Extracted {} custom names from current resource '{}' (type: {:?})",
1103            current_custom_names.len(),
1104            current_resource.name,
1105            current_resource.resource_type
1106        );
1107        if !current_custom_names.is_empty() || current_resource.name.contains("golang") {
1108            tracing::info!(
1109                "Extracted {} custom names from current resource '{}' (type: {:?})",
1110                current_custom_names.len(),
1111                current_resource.name,
1112                current_resource.resource_type
1113            );
1114            for (dep_ref, custom_name) in &current_custom_names {
1115                tracing::info!("  Will add alias: '{}' -> '{}'", dep_ref, custom_name);
1116            }
1117        }
1118        for (dep_ref, custom_name) in current_custom_names {
1119            add_custom_alias(&mut deps, &dep_ref, &custom_name);
1120        }
1121
1122        // Debug: Print what we built
1123        tracing::debug!(
1124            "Built dependencies data with {} resource types for '{}'",
1125            deps.len(),
1126            current_resource.name
1127        );
1128        for (resource_type, resources) in &deps {
1129            tracing::debug!("  Type {}: {} resources", resource_type, resources.len());
1130            if resource_type == "snippets" {
1131                for (key, data) in resources {
1132                    tracing::debug!(
1133                        "    - key='{}', name='{}', path='{}'",
1134                        key,
1135                        data.name,
1136                        data.path
1137                    );
1138                }
1139            } else {
1140                for name in resources.keys() {
1141                    tracing::debug!("    - {}", name);
1142                }
1143            }
1144        }
1145
1146        Ok(deps)
1147    }
1148
1149    /// Build context with visited tracking (for recursive rendering).
1150    ///
1151    /// This method should be implemented by the context builder to support
1152    /// recursive template rendering with cycle detection.
1153    async fn build_context_with_visited(
1154        &self,
1155        resource_id: &ResourceId,
1156        variant_inputs: &serde_json::Value,
1157        rendering_stack: &mut HashSet<String>,
1158    ) -> Result<tera::Context>;
1159}
1160
1161/// Helper function to add a custom name alias to the dependencies map.
1162///
1163/// This function searches for an already-processed resource in the `deps` map and creates
1164/// an alias entry with the custom name. The resource should have already been added to
1165/// `deps` with its path-based key during the main processing loop.
1166///
1167/// Note: This function doesn't need to do lockfile lookups with ResourceId because it
1168/// searches within the already-built `deps` map. The deps map was built from the lockfile
1169/// with all the correct template_vars and content.
1170pub(crate) fn add_custom_alias(
1171    deps: &mut BTreeMap<String, BTreeMap<String, DependencyData>>,
1172    dep_ref: &str,
1173    custom_name: &str,
1174) {
1175    // Parse dependency reference using centralized LockfileDependencyRef logic
1176    let dep_ref_parsed = match LockfileDependencyRef::from_str(dep_ref) {
1177        Ok(dep_ref) => dep_ref,
1178        Err(e) => {
1179            tracing::debug!(
1180                "Skipping invalid dep_ref format '{}' for custom name '{}': {}",
1181                dep_ref,
1182                custom_name,
1183                e
1184            );
1185            return;
1186        }
1187    };
1188
1189    let dep_type = dep_ref_parsed.resource_type;
1190    let dep_name = &dep_ref_parsed.path;
1191
1192    let type_str_plural = dep_type.to_plural().to_string();
1193
1194    // Search for the resource in the deps map (already populated from lockfile)
1195    if let Some(type_deps) = deps.get_mut(&type_str_plural) {
1196        // Build name → key index for O(1) lookup instead of O(N²) linear search
1197        let name_to_key: HashMap<String, String> = type_deps
1198            .iter()
1199            .flat_map(|(key, data)| {
1200                // Map both the full name and various fallback names to the key
1201                let mut mappings = vec![(data.name.clone(), key.clone())];
1202
1203                // Add basename fallbacks for direct manifest deps
1204                if let Some(basename) = Path::new(&data.name).file_name().and_then(|n| n.to_str()) {
1205                    mappings.push((basename.to_string(), key.clone()));
1206                }
1207                if let Some(stem) = Path::new(&data.path).file_stem().and_then(|n| n.to_str()) {
1208                    mappings.push((stem.to_string(), key.clone()));
1209                }
1210                if let Some(path_basename) =
1211                    Path::new(&data.path).file_name().and_then(|n| n.to_str())
1212                {
1213                    mappings.push((path_basename.to_string(), key.clone()));
1214                }
1215
1216                mappings
1217            })
1218            .collect();
1219
1220        // Find the resource by name using O(1) lookup
1221        let existing_data =
1222            name_to_key.get(dep_name).and_then(|key| type_deps.get(key).cloned()).or_else(|| {
1223                // Some direct manifest dependencies use the bare manifest key (no type prefix)
1224                // even though transitive refs include the source-relative path (snippets/foo/bar).
1225                // Fall back to matching by the last path segment to align the two representations.
1226                Path::new(dep_name)
1227                    .file_name()
1228                    .and_then(|name| name.to_str())
1229                    .and_then(|basename| name_to_key.get(basename))
1230                    .and_then(|key| type_deps.get(key).cloned())
1231            });
1232
1233        if let Some(data) = existing_data {
1234            // Sanitize the alias (replace hyphens with underscores for Tera)
1235            let sanitized_alias = custom_name.replace('-', "_");
1236
1237            tracing::info!(
1238                "✓ Added {} alias '{}' -> resource '{}' (path: {})",
1239                type_str_plural,
1240                sanitized_alias,
1241                dep_name,
1242                data.path
1243            );
1244
1245            // Add an alias entry pointing to the same data
1246            type_deps.insert(sanitized_alias.clone(), data);
1247        } else {
1248            tracing::error!(
1249                "❌ NOT FOUND: {} resource '{}' for alias '{}'.\n  \
1250                Dep ref: '{}'\n  \
1251                Available {} (first 5): {}",
1252                type_str_plural,
1253                dep_name,
1254                custom_name,
1255                dep_ref,
1256                type_deps.len(),
1257                type_deps
1258                    .iter()
1259                    .take(5)
1260                    .map(|(k, v)| format!("'{}' (name='{}')", k, v.name))
1261                    .collect::<Vec<_>>()
1262                    .join(", ")
1263            );
1264        }
1265    } else {
1266        tracing::debug!(
1267            "Resource type '{}' not found in deps map when adding custom alias '{}' for '{}'",
1268            type_str_plural,
1269            custom_name,
1270            dep_ref
1271        );
1272    }
1273}