agpm_cli/templating/dependencies/extractors.rs
1//! Dependency extraction functionality for templates.
2//!
3//! This module provides methods for extracting custom dependency names and
4//! dependency specifications from resource files.
5
6use crate::core::file_error::{FileOperation, FileResultExt};
7use anyhow::{Result, bail};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::sync::Arc;
10
11use crate::core::ResourceType;
12use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
13use crate::lockfile::{LockFile, LockedResource, ResourceId};
14
15use crate::templating::cache::RenderCache;
16use crate::templating::content::ContentExtractor;
17
18/// Helper function to create a LockfileDependencyRef string from a resource.
19///
20/// This centralizes logic for creating dependency references based on whether
21/// resource has a source (Git) or is local.
22pub(crate) fn create_dependency_ref_string(
23 source: Option<&str>,
24 resource_type: ResourceType,
25 name: &str,
26 version: Option<&str>,
27) -> String {
28 if let Some(source) = source {
29 LockfileDependencyRef::git(
30 source.to_string(),
31 resource_type,
32 name.to_string(),
33 version.map(|v| v.to_string()),
34 )
35 .to_string()
36 } else {
37 LockfileDependencyRef::local(
38 resource_type,
39 name.to_string(),
40 version.map(|v| v.to_string()),
41 )
42 .to_string()
43 }
44}
45
46/// Canonicalize a dependency path relative to a resource path.
47///
48/// If the dependency path is relative (starts with `../` or `./`), resolves it
49/// relative to the resource's parent directory and normalizes for storage.
50/// Otherwise, returns the path as-is.
51///
52/// # Arguments
53///
54/// * `dep_path` - The dependency path from frontmatter
55/// * `resource_path` - The path of the resource declaring the dependency
56///
57/// # Returns
58///
59/// Canonical path suitable for lockfile lookups
60///
61/// # Examples
62///
63/// ```
64/// // This example demonstrates the canonicalize_dep_path function behavior
65/// // The function is internal to the crate, but its behavior is tested below
66///
67/// // Relative path resolution: "../utils/helper.md" from "agents/primary.md"
68/// // would result in "utils/helper.md"
69///
70/// // Absolute path passes through: "agents/helper.md" from "agents/primary.md"
71/// // would result in "agents/helper.md"
72/// ```
73pub(crate) fn canonicalize_dep_path(dep_path: &str, resource_path: &str) -> String {
74 if dep_path.starts_with("../") || dep_path.starts_with("./") {
75 // Relative path - resolve using source-relative paths, not filesystem paths
76 // Get the parent directory of the resource within the source
77 let resource_parent = std::path::Path::new(resource_path)
78 .parent()
79 .unwrap_or_else(|| std::path::Path::new(""));
80
81 // Join with the relative dependency path (still may have ..)
82 let joined = resource_parent.join(dep_path);
83
84 // Normalize to remove .. and . components, then format for storage
85 let normalized = crate::utils::normalize_path(&joined);
86 crate::utils::normalize_path_for_storage(&normalized)
87 } else {
88 // Absolute or already canonical
89 dep_path.to_string()
90 }
91}
92
93/// Trait for dependency extraction methods on TemplateContextBuilder.
94pub(crate) trait DependencyExtractor: ContentExtractor {
95 /// Get the lockfile
96 fn lockfile(&self) -> &Arc<LockFile>;
97
98 /// Get the render cache
99 fn render_cache(&self) -> &Arc<std::sync::Mutex<RenderCache>>;
100
101 /// Get the custom names cache
102 fn custom_names_cache(
103 &self,
104 ) -> &Arc<std::sync::Mutex<HashMap<String, BTreeMap<String, String>>>>;
105
106 /// Get the dependency specs cache
107 fn dependency_specs_cache(
108 &self,
109 ) -> &Arc<std::sync::Mutex<HashMap<String, BTreeMap<String, crate::manifest::DependencySpec>>>>;
110
111 /// Extract custom dependency names from a resource's frontmatter.
112 ///
113 /// Parses the resource file to extract the `dependencies` declaration with `name:` fields
114 /// and maps dependency references to their custom names.
115 ///
116 /// # Returns
117 ///
118 /// A BTreeMap mapping dependency references (e.g., "snippet/rust-best-practices") to custom
119 /// names (e.g., "best_practices") as declared in the resource's YAML frontmatter.
120 /// BTreeMap ensures deterministic iteration order for consistent context checksums.
121 ///
122 /// # Errors
123 ///
124 /// Returns an error if the dependency file cannot be read or parsed.
125 async fn extract_dependency_custom_names(
126 &self,
127 resource: &LockedResource,
128 ) -> Result<BTreeMap<String, String>> {
129 tracing::info!(
130 "[EXTRACT_CUSTOM_NAMES] Called for resource '{}' (type: {:?}), variant_inputs: {:?}",
131 resource.name,
132 resource.resource_type,
133 resource.variant_inputs.json()
134 );
135
136 // Build cache key from resource name and type
137 let cache_key = format!("{}@{:?}", resource.name, resource.resource_type);
138
139 // Check cache first
140 if let Ok(cache) = self.custom_names_cache().lock() {
141 if let Some(cached_names) = cache.get(&cache_key) {
142 tracing::info!(
143 "Custom names cache HIT for '{}' ({} names)",
144 resource.name,
145 cached_names.len()
146 );
147 return Ok(cached_names.clone());
148 }
149 }
150
151 tracing::info!("Custom names cache MISS for '{}', extracting from file", resource.name);
152
153 let mut custom_names = BTreeMap::new();
154
155 // Build a lookup structure upfront to avoid O(n³) nested loops
156 // Map: type -> Vec<(basename, full_dep_ref)>
157 // Use BTreeMap for deterministic iteration order
158 let mut lockfile_lookup: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
159
160 // Use parsed_dependencies() helper to parse all dependencies from lockfile
161 for dep_ref in resource.parsed_dependencies() {
162 let lockfile_type = dep_ref.resource_type.to_string();
163 let lockfile_name = &dep_ref.path;
164 let lockfile_dep_ref = dep_ref.to_string();
165
166 // Extract basename from lockfile name
167 let lockfile_basename = std::path::Path::new(lockfile_name)
168 .file_stem()
169 .and_then(|s| s.to_str())
170 .unwrap_or(lockfile_name)
171 .to_string();
172
173 lockfile_lookup
174 .entry(lockfile_type)
175 .or_default()
176 .push((lockfile_basename, lockfile_dep_ref));
177 }
178
179 // Determine source path (same logic as extract_content)
180 let source_path = if let Some(_source_name) = &resource.source {
181 // Has source - check if local or Git
182 let url = match resource.url.as_ref() {
183 Some(u) => u,
184 None => bail!("Resource '{}' has source but no URL", resource.name),
185 };
186
187 if resource.is_local() {
188 // Local source
189 std::path::PathBuf::from(url).join(&resource.path)
190 } else {
191 // Git source
192 let sha = match resource.resolved_commit.as_deref() {
193 Some(s) => s,
194 None => bail!("Resource '{}' has no resolved commit", resource.name),
195 };
196 match self.cache().get_worktree_path(url, sha) {
197 Ok(worktree_dir) => worktree_dir.join(&resource.path),
198 Err(e) => {
199 bail!("Failed to get worktree path for resource '{}': {}", resource.name, e)
200 }
201 }
202 }
203 } else {
204 // Local file
205 let local_path = std::path::Path::new(&resource.path);
206 if local_path.is_absolute() {
207 local_path.to_path_buf()
208 } else {
209 self.project_dir().join(local_path)
210 }
211 };
212
213 // Read and parse the file based on type
214 if resource.path.ends_with(".md") {
215 // Parse markdown frontmatter with template rendering
216 let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
217 FileOperation::Read,
218 &source_path,
219 "reading markdown dependency file",
220 "templating_dependencies",
221 )?;
222
223 // Use templated parsing to handle conditional blocks ({% if %}) in frontmatter
224 if let Ok(doc) = crate::markdown::MarkdownDocument::parse_with_templating(
225 &content,
226 Some(resource.variant_inputs.json()),
227 Some(&source_path),
228 ) {
229 // Extract dependencies from parsed metadata
230 if let Some(markdown_metadata) = &doc.metadata {
231 // Convert MarkdownMetadata to DependencyMetadata
232 // Merge both root-level dependencies and agpm.dependencies
233 let dependency_metadata = crate::manifest::DependencyMetadata::new(
234 markdown_metadata.dependencies.clone(),
235 markdown_metadata.get_agpm_metadata(),
236 );
237
238 if let Some(deps_map) = dependency_metadata.get_dependencies() {
239 // Process each resource type (agents, snippets, commands, etc.)
240 for (resource_type_str, deps_array) in deps_map {
241 // Convert frontmatter type to lockfile type (singular)
242 let Some(resource_type) =
243 crate::core::ResourceType::from_frontmatter_str(
244 resource_type_str.as_str(),
245 )
246 else {
247 continue; // Skip unknown types
248 };
249 let lockfile_type = resource_type.to_string();
250
251 // Get lockfile entries for this type only (O(1) lookup instead of O(n) iteration)
252 let type_entries = match lockfile_lookup.get(&lockfile_type) {
253 Some(entries) => entries,
254 None => continue, // No lockfile deps of this type
255 };
256
257 // deps_array is Vec<DependencySpec>
258 for dep_spec in deps_array {
259 let path = &dep_spec.path;
260 if let Some(custom_name) = &dep_spec.name {
261 // Extract basename from the path (without extension)
262 let basename = std::path::Path::new(path)
263 .file_stem()
264 .and_then(|s| s.to_str())
265 .unwrap_or(path);
266
267 tracing::info!(
268 "Found custom name '{}' for path '{}' (basename: '{}') in resource '{}'",
269 custom_name,
270 path,
271 basename,
272 resource.name
273 );
274
275 // Check if basename has template variables
276 if basename.contains("{{") {
277 // Template variable in basename - try suffix matching
278 // e.g., "{{ agpm.project.language }}-best-practices" -> "-best-practices"
279 if let Some(static_suffix_start) = basename.find("}}") {
280 let static_suffix =
281 &basename[static_suffix_start + 2..];
282
283 tracing::info!(
284 " Extracted suffix '{}' from templated basename '{}' in resource '{}'",
285 static_suffix,
286 basename,
287 resource.name
288 );
289
290 // Search for any lockfile basename ending with this suffix
291 let mut found_count = 0;
292 for (lockfile_basename, lockfile_dep_ref) in
293 type_entries
294 {
295 tracing::info!(
296 " Checking lockfile basename '{}' against suffix '{}': match={}",
297 lockfile_basename,
298 static_suffix,
299 lockfile_basename.ends_with(static_suffix)
300 );
301
302 if lockfile_basename.ends_with(static_suffix) {
303 tracing::info!(
304 " [MATCH] Adding custom name '{}' for lockfile entry '{}' (basename: '{}')",
305 custom_name,
306 lockfile_dep_ref,
307 lockfile_basename
308 );
309 custom_names.insert(
310 lockfile_dep_ref.clone(),
311 custom_name.to_string(),
312 );
313 found_count += 1;
314 }
315 }
316
317 if found_count == 0 {
318 tracing::warn!(
319 " [NO MATCH] No lockfile entries found ending with suffix '{}' for custom name '{}' in resource '{}'",
320 static_suffix,
321 custom_name,
322 resource.name
323 );
324 }
325 }
326 } else {
327 // No template variables - exact basename match (O(n) but only within type)
328 for (lockfile_basename, lockfile_dep_ref) in type_entries {
329 if lockfile_basename == basename {
330 custom_names.insert(
331 lockfile_dep_ref.clone(),
332 custom_name.to_string(),
333 );
334 break; // Found exact match, no need to continue
335 }
336 }
337 }
338 }
339 }
340 }
341 }
342 }
343 }
344 } else if resource.path.ends_with(".json") {
345 // Parse JSON dependencies field with template rendering
346 let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
347 FileOperation::Read,
348 &source_path,
349 "reading JSON dependency file",
350 "templating_dependencies",
351 )?;
352
353 // Apply templating to JSON content to handle conditional blocks
354 let mut parser = crate::markdown::frontmatter::FrontmatterParser::new();
355 let templated_content = parser
356 .apply_templating(&content, Some(resource.variant_inputs.json()), &source_path)
357 .unwrap_or_else(|_| content.clone());
358
359 // Parse JSON and extract dependencies field
360 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&templated_content) {
361 // Extract both root-level dependencies and agpm.dependencies
362 let root_deps = json_value.get("dependencies").and_then(|v| {
363 serde_json::from_value::<
364 BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
365 >(v.clone())
366 .ok()
367 });
368
369 let agpm_metadata = json_value.get("agpm").and_then(|v| {
370 serde_json::from_value::<crate::manifest::dependency_spec::AgpmMetadata>(
371 v.clone(),
372 )
373 .ok()
374 });
375
376 // Merge both dependency sources
377 let dependency_metadata =
378 crate::manifest::DependencyMetadata::new(root_deps, agpm_metadata);
379
380 if let Some(deps_map) = dependency_metadata.get_dependencies() {
381 // Process each resource type (agents, snippets, commands, etc.)
382 for (resource_type_str, deps_array) in deps_map {
383 // Convert frontmatter type to lockfile type (singular)
384 let Some(resource_type) = crate::core::ResourceType::from_frontmatter_str(
385 resource_type_str.as_str(),
386 ) else {
387 continue; // Skip unknown types
388 };
389 let lockfile_type = resource_type.to_string();
390
391 // Get lockfile entries for this type only (O(1) lookup instead of O(n) iteration)
392 let type_entries = match lockfile_lookup.get(&lockfile_type) {
393 Some(entries) => entries,
394 None => continue, // No lockfile deps of this type
395 };
396
397 // deps_array is Vec<DependencySpec>
398 for dep_spec in deps_array {
399 let path = &dep_spec.path;
400 if let Some(custom_name) = &dep_spec.name {
401 // Extract basename from the path (without extension)
402 let basename = std::path::Path::new(path)
403 .file_stem()
404 .and_then(|s| s.to_str())
405 .unwrap_or(path);
406
407 tracing::info!(
408 "Found custom name '{}' for path '{}' (basename: '{}') from JSON",
409 custom_name,
410 path,
411 basename
412 );
413
414 // Check if basename has template variables
415 if basename.contains("{{") {
416 // Template variable in basename - try suffix matching
417 // e.g., "{{ agpm.project.language }}-best-practices" -> "-best-practices"
418 if let Some(static_suffix_start) = basename.find("}}") {
419 let static_suffix = &basename[static_suffix_start + 2..];
420
421 // Search for any lockfile basename ending with this suffix
422 for (lockfile_basename, lockfile_dep_ref) in type_entries {
423 if lockfile_basename.ends_with(static_suffix) {
424 custom_names.insert(
425 lockfile_dep_ref.clone(),
426 custom_name.to_string(),
427 );
428 }
429 }
430 }
431 } else {
432 // No template variables - exact basename match (O(n) but only within type)
433 for (lockfile_basename, lockfile_dep_ref) in type_entries {
434 if lockfile_basename == basename {
435 custom_names.insert(
436 lockfile_dep_ref.clone(),
437 custom_name.to_string(),
438 );
439 break; // Found exact match, no need to continue
440 }
441 }
442 }
443 }
444 }
445 }
446 }
447 }
448 }
449
450 // Store in cache before returning
451 if let Ok(mut cache) = self.custom_names_cache().lock() {
452 cache.insert(cache_key, custom_names.clone());
453 tracing::info!(
454 "[EXTRACT_RESULT] Extracted and stored {} custom names in cache for resource '{}' (type: {:?})",
455 custom_names.len(),
456 resource.name,
457 resource.resource_type
458 );
459 }
460
461 if custom_names.is_empty() {
462 tracing::warn!(
463 "[EXTRACT_EMPTY] No custom names found for resource '{}' (type: {:?}). lockfile_lookup had {} types, resource has {} dependencies",
464 resource.name,
465 resource.resource_type,
466 lockfile_lookup.len(),
467 resource.dependencies.len()
468 );
469 }
470
471 Ok(custom_names)
472 }
473
474 /// Extract full dependency specifications from a resource's frontmatter.
475 ///
476 /// Parses the resource file to extract complete DependencySpec objects including
477 /// tool, name, flatten, and install fields. This information is used to build
478 /// complete ResourceIds for dependency lookups.
479 ///
480 /// # Returns
481 ///
482 /// A BTreeMap mapping dependency references (e.g., "snippet:snippets/commands/commit")
483 /// to their full DependencySpec objects. BTreeMap ensures deterministic iteration.
484 ///
485 /// # Errors
486 ///
487 /// Returns an error if the dependency file cannot be read or parsed.
488 async fn extract_dependency_specs(
489 &self,
490 resource: &LockedResource,
491 ) -> Result<BTreeMap<String, crate::manifest::DependencySpec>> {
492 // Build cache key from resource name and type
493 let cache_key = format!("{}@{:?}", resource.name, resource.resource_type);
494
495 // Check cache first
496 if let Ok(cache) = self.dependency_specs_cache().lock() {
497 if let Some(cached_specs) = cache.get(&cache_key) {
498 tracing::debug!(
499 "Dependency specs cache HIT for '{}' ({} specs)",
500 resource.name,
501 cached_specs.len()
502 );
503 return Ok(cached_specs.clone());
504 }
505 }
506
507 tracing::debug!("Dependency specs cache MISS for '{}'", resource.name);
508
509 let mut dependency_specs = BTreeMap::new();
510
511 // Determine source path (same logic as extract_content)
512 let source_path = if let Some(_source_name) = &resource.source {
513 // Has source - check if local or Git
514 let url = match resource.url.as_ref() {
515 Some(u) => u,
516 None => bail!("Resource '{}' has source but no URL", resource.name),
517 };
518
519 if resource.is_local() {
520 // Local source
521 std::path::PathBuf::from(url).join(&resource.path)
522 } else {
523 // Git source
524 let sha = match resource.resolved_commit.as_deref() {
525 Some(s) => s,
526 None => bail!("Resource '{}' has no resolved commit", resource.name),
527 };
528 match self.cache().get_worktree_path(url, sha) {
529 Ok(worktree_dir) => worktree_dir.join(&resource.path),
530 Err(e) => {
531 bail!("Failed to get worktree path for resource '{}': {}", resource.name, e)
532 }
533 }
534 }
535 } else {
536 // Local file
537 let local_path = std::path::Path::new(&resource.path);
538 if local_path.is_absolute() {
539 local_path.to_path_buf()
540 } else {
541 self.project_dir().join(local_path)
542 }
543 };
544
545 // Read and parse the file based on type
546 if resource.path.ends_with(".md") {
547 // Parse markdown frontmatter with template rendering
548 let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
549 FileOperation::Read,
550 &source_path,
551 "reading markdown dependency file",
552 "templating_dependencies",
553 )?;
554
555 // Use templated parsing to handle conditional blocks ({% if %}) in frontmatter
556 if let Ok(doc) = crate::markdown::MarkdownDocument::parse_with_templating(
557 &content,
558 Some(resource.variant_inputs.json()),
559 Some(&source_path),
560 ) {
561 // Extract dependencies from parsed metadata
562 if let Some(markdown_metadata) = &doc.metadata {
563 // Convert MarkdownMetadata to DependencyMetadata
564 let dependency_metadata = crate::manifest::DependencyMetadata::new(
565 markdown_metadata.dependencies.clone(),
566 markdown_metadata.get_agpm_metadata(),
567 );
568
569 if let Some(deps_map) = dependency_metadata.get_dependencies() {
570 // Process each resource type
571 for (resource_type_str, deps_array) in deps_map {
572 // Convert frontmatter type to ResourceType
573 let Some(resource_type) =
574 crate::core::ResourceType::from_frontmatter_str(
575 resource_type_str.as_str(),
576 )
577 else {
578 continue;
579 };
580
581 // Store each DependencySpec with its lockfile reference as key
582 for dep_spec in deps_array {
583 // Canonicalize the frontmatter path to match lockfile format
584 // Frontmatter paths are relative to the resource file itself
585 // We need to resolve them relative to source root (not filesystem paths!)
586 let canonical_path =
587 canonicalize_dep_path(&dep_spec.path, &resource.path);
588
589 // Remove extension to match lockfile format
590 let normalized_path = std::path::Path::new(&canonical_path)
591 .with_extension("")
592 .to_string_lossy()
593 .to_string();
594
595 // Build the dependency reference string WITHOUT version
596 // Cache key should only use path to match any version of this dependency
597 // Version is for resolution purposes, not for identifying the spec
598 let dep_ref = if let Some(ref src) = resource.source {
599 LockfileDependencyRef::git(
600 src.clone(),
601 resource_type,
602 normalized_path,
603 None, // No version in cache key
604 )
605 .to_string()
606 } else {
607 LockfileDependencyRef::local(
608 resource_type,
609 normalized_path,
610 None, // No version in cache key
611 )
612 .to_string()
613 };
614
615 dependency_specs.insert(dep_ref, dep_spec.clone());
616 }
617 }
618 }
619 }
620 }
621 } else if resource.path.ends_with(".json") {
622 // Parse JSON dependencies field with template rendering
623 let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
624 FileOperation::Read,
625 &source_path,
626 "reading JSON dependency file",
627 "templating_dependencies",
628 )?;
629
630 // Apply templating to JSON content to handle conditional blocks
631 let mut parser = crate::markdown::frontmatter::FrontmatterParser::new();
632 let templated_content = parser
633 .apply_templating(&content, Some(resource.variant_inputs.json()), &source_path)
634 .unwrap_or_else(|_| content.clone());
635
636 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&templated_content) {
637 // Extract both root-level dependencies and agpm.dependencies
638 let root_deps = json_value.get("dependencies").and_then(|v| {
639 serde_json::from_value::<
640 BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
641 >(v.clone())
642 .ok()
643 });
644
645 let agpm_metadata = json_value.get("agpm").and_then(|v| {
646 serde_json::from_value::<crate::manifest::dependency_spec::AgpmMetadata>(
647 v.clone(),
648 )
649 .ok()
650 });
651
652 // Merge both dependency sources
653 let dependency_metadata =
654 crate::manifest::DependencyMetadata::new(root_deps, agpm_metadata);
655
656 if let Some(deps_map) = dependency_metadata.get_dependencies() {
657 // Process each resource type
658 for (resource_type_str, deps_array) in deps_map {
659 // Convert frontmatter type to ResourceType
660 let resource_type = match resource_type_str.as_str() {
661 "agents" | "agent" => crate::core::ResourceType::Agent,
662 "snippets" | "snippet" => crate::core::ResourceType::Snippet,
663 "commands" | "command" => crate::core::ResourceType::Command,
664 "scripts" | "script" => crate::core::ResourceType::Script,
665 "hooks" | "hook" => crate::core::ResourceType::Hook,
666 "mcp-servers" | "mcp-server" => crate::core::ResourceType::McpServer,
667 _ => continue,
668 };
669
670 // Store each DependencySpec with its lockfile reference as key
671 for dep_spec in deps_array {
672 // Canonicalize the frontmatter path to match lockfile format
673 // Frontmatter paths are relative to the resource file itself
674 // We need to resolve them relative to source root (not filesystem paths!)
675 let canonical_path =
676 canonicalize_dep_path(&dep_spec.path, &resource.path);
677
678 // Remove extension to match lockfile format
679 let normalized_path = std::path::Path::new(&canonical_path)
680 .with_extension("")
681 .to_string_lossy()
682 .to_string();
683
684 // Build the dependency reference string WITHOUT version
685 // Cache key should only use path to match any version of this dependency
686 // Version is for resolution purposes, not for identifying the spec
687 let dep_ref = if let Some(ref src) = resource.source {
688 LockfileDependencyRef::git(
689 src.clone(),
690 resource_type,
691 normalized_path,
692 None, // No version in cache key
693 )
694 .to_string()
695 } else {
696 LockfileDependencyRef::local(
697 resource_type,
698 normalized_path,
699 None, // No version in cache key
700 )
701 .to_string()
702 };
703
704 dependency_specs.insert(dep_ref, dep_spec.clone());
705 }
706 }
707 }
708 }
709 }
710
711 // Store in cache before returning
712 if let Ok(mut cache) = self.dependency_specs_cache().lock() {
713 cache.insert(cache_key, dependency_specs.clone());
714 tracing::debug!(
715 "Stored {} dependency specs in cache for '{}'",
716 dependency_specs.len(),
717 resource.name
718 );
719 }
720
721 Ok(dependency_specs)
722 }
723
724 /// Generate dependency name from a path (matching resolver logic).
725 ///
726 /// For local transitive dependencies, the resolver uses the full relative path
727 /// (without extension) as the resource name to maintain uniqueness.
728 /// Build dependency data for the template context.
729 ///
730 /// This creates a nested structure containing:
731 /// 1. ALL resources from the lockfile (path-based names) - for universal access
732 /// 2. Current resource's declared dependencies (custom alias names) - for scoped access
733 ///
734 /// This dual approach ensures:
735 /// - Any resource can access any other resource via path-based names
736 /// - Resources can use custom aliases for their dependencies without collisions
737 ///
738 /// # Arguments
739 ///
740 /// * `current_resource` - The resource being rendered (for scoped alias mapping)
741 async fn build_dependencies_data(
742 &self,
743 current_resource: &crate::lockfile::LockedResource,
744 rendering_stack: &mut HashSet<String>,
745 ) -> Result<BTreeMap<String, BTreeMap<String, crate::templating::context::DependencyData>>>;
746
747 /// Build context with visited tracking (for recursive rendering).
748 ///
749 /// This method should be implemented by the context builder to support
750 /// recursive template rendering with cycle detection.
751 async fn build_context_with_visited(
752 &self,
753 resource_id: &ResourceId,
754 variant_inputs: &serde_json::Value,
755 rendering_stack: &mut HashSet<String>,
756 ) -> Result<tera::Context>;
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 #[test]
764 fn test_canonicalize_dep_path_relative_up() {
765 // Test relative path with ../
766 let result = canonicalize_dep_path("../utils/helper.md", "agents/primary.md");
767 assert_eq!(result, "utils/helper.md");
768 }
769
770 #[test]
771 fn test_canonicalize_dep_path_relative_current() {
772 // Test relative path with ./
773 let result = canonicalize_dep_path("./helper.md", "agents/primary.md");
774 assert_eq!(result, "agents/helper.md");
775 }
776
777 #[test]
778 fn test_canonicalize_dep_path_relative_nested() {
779 // Test nested relative path
780 let result = canonicalize_dep_path("../utils/helper.md", "agents/ai/assistant.md");
781 assert_eq!(result, "agents/utils/helper.md");
782 }
783
784 #[test]
785 fn test_canonicalize_dep_path_absolute() {
786 // Test absolute path (passes through)
787 let result = canonicalize_dep_path("agents/helper.md", "agents/primary.md");
788 assert_eq!(result, "agents/helper.md");
789 }
790
791 #[test]
792 fn test_canonicalize_dep_path_absolute_nested() {
793 // Test absolute nested path
794 let result = canonicalize_dep_path("snippets/utils/helper.md", "agents/primary.md");
795 assert_eq!(result, "snippets/utils/helper.md");
796 }
797
798 #[test]
799 fn test_canonicalize_dep_path_root_resource() {
800 // Test with resource at root (no parent directory)
801 let result = canonicalize_dep_path("./agents/helper.md", "root.md");
802 assert_eq!(result, "agents/helper.md");
803 }
804
805 #[test]
806 fn test_canonicalize_dep_path_multiple_levels_up() {
807 // Test multiple levels up
808 let result = canonicalize_dep_path("../../shared/base.md", "agents/ai/models/gpt.md");
809 assert_eq!(result, "agents/shared/base.md");
810 }
811
812 #[test]
813 fn test_canonicalize_dep_path_same_directory() {
814 // Test same directory reference
815 let result = canonicalize_dep_path("./helper.md", "agents/primary.md");
816 assert_eq!(result, "agents/helper.md");
817 }
818
819 #[test]
820 fn test_canonicalize_dep_path_no_extension() {
821 // Test path without extension
822 let result = canonicalize_dep_path("../utils/helper", "agents/primary.md");
823 assert_eq!(result, "utils/helper");
824 }
825
826 #[test]
827 fn test_canonicalize_dep_path_with_complex_extension() {
828 // Test path with complex extension
829 let result = canonicalize_dep_path("../scripts/setup.sh", "agents/primary.md");
830 assert_eq!(result, "scripts/setup.sh");
831 }
832}