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}