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 ¤t_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 ¤t_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}