agpm_cli/resolver/
transitive_extractor.rs

1//! Transitive dependency extraction.
2//!
3//! This module provides reusable functions for extracting transitive dependencies
4//! from resource files. Used by both the main transitive resolver and the
5//! backtracking resolver for re-extracting dependencies after version changes.
6
7use anyhow::{Context, Result};
8use std::collections::HashMap;
9use std::path::Path;
10
11use crate::core::ResourceType;
12use crate::manifest::DependencySpec;
13use crate::metadata::MetadataExtractor;
14
15/// Extract transitive dependencies from a resource file.
16///
17/// This is a simplified extraction function that reads a resource file,
18/// extracts its metadata, and returns the raw dependency specifications
19/// grouped by resource type.
20///
21/// # Arguments
22///
23/// * `worktree_path` - Path to the worktree containing the resource
24/// * `resource_path` - Relative path to the resource file within worktree
25/// * `variant_inputs` - Optional template variables for frontmatter rendering
26///
27/// # Returns
28///
29/// Map of resource_type → Vec<`DependencySpec`>
30///
31/// # Example
32///
33/// ```no_run
34/// use std::path::Path;
35/// use agpm_cli::resolver::transitive_extractor::extract_transitive_deps;
36///
37/// # async fn example() -> anyhow::Result<()> {
38/// let worktree = Path::new("/path/to/worktree");
39/// let resource = "agents/helper.md";
40///
41/// let deps = extract_transitive_deps(worktree, resource, None).await?;
42/// for (resource_type, specs) in deps {
43///     println!("{:?}: {} dependencies", resource_type, specs.len());
44/// }
45/// # Ok(())
46/// # }
47/// ```
48pub async fn extract_transitive_deps(
49    worktree_path: &Path,
50    resource_path: &str,
51    variant_inputs: Option<&serde_json::Value>,
52) -> Result<HashMap<ResourceType, Vec<DependencySpec>>> {
53    // Build full path to the resource file
54    let file_path = worktree_path.join(resource_path);
55
56    // Read file content
57    let content = tokio::fs::read_to_string(&file_path)
58        .await
59        .with_context(|| format!("Failed to read resource file: {}", file_path.display()))?;
60
61    // Extract metadata (no operation context needed for backtracking)
62    let metadata = MetadataExtractor::extract(&file_path, &content, variant_inputs, None)
63        .with_context(|| format!("Failed to extract metadata from: {}", file_path.display()))?;
64
65    // Get typed dependencies (with ResourceType keys)
66    let deps = metadata.get_dependencies_typed().unwrap_or_default();
67
68    // Log extracted dependencies for debugging
69    for (resource_type, specs) in &deps {
70        for spec in specs {
71            tracing::debug!(
72                "EXTRACT: {} extracted from '{}' -> path='{}' version='{}'",
73                resource_type,
74                resource_path,
75                spec.path,
76                spec.version.as_deref().unwrap_or("HEAD")
77            );
78        }
79    }
80
81    Ok(deps)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    use tempfile::TempDir;
89
90    #[tokio::test]
91    async fn test_extract_from_markdown_with_frontmatter() {
92        let temp_dir = TempDir::new().unwrap();
93        let file_path = temp_dir.path().join("test.md");
94
95        let content = r#"---
96dependencies:
97  agents:
98    - path: agents/helper.md
99      version: v1.0.0
100  snippets:
101    - path: snippets/guide.md
102---
103# Test Agent
104"#;
105
106        tokio::fs::write(&file_path, content).await.unwrap();
107
108        let deps = extract_transitive_deps(temp_dir.path(), "test.md", None).await.unwrap();
109
110        assert_eq!(deps.len(), 2);
111        assert!(deps.contains_key(&ResourceType::Agent));
112        assert!(deps.contains_key(&ResourceType::Snippet));
113
114        let agents = &deps[&ResourceType::Agent];
115        assert_eq!(agents.len(), 1);
116        assert_eq!(agents[0].path, "agents/helper.md");
117        assert_eq!(agents[0].version.as_deref(), Some("v1.0.0"));
118    }
119
120    #[tokio::test]
121    async fn test_extract_from_file_without_dependencies() {
122        let temp_dir = TempDir::new().unwrap();
123        let file_path = temp_dir.path().join("test.md");
124
125        let content = "# Simple Agent\n\nNo dependencies here.";
126        tokio::fs::write(&file_path, content).await.unwrap();
127
128        let deps = extract_transitive_deps(temp_dir.path(), "test.md", None).await.unwrap();
129
130        assert_eq!(deps.len(), 0);
131    }
132
133    #[tokio::test]
134    async fn test_extract_from_json() {
135        let temp_dir = TempDir::new().unwrap();
136        let file_path = temp_dir.path().join("test.json");
137
138        let content = r#"{
139  "name": "test",
140  "dependencies": {
141    "agents": [
142      {
143        "path": "agents/helper.md",
144        "version": "v1.0.0"
145      }
146    ]
147  }
148}"#;
149
150        tokio::fs::write(&file_path, content).await.unwrap();
151
152        let deps = extract_transitive_deps(temp_dir.path(), "test.json", None).await.unwrap();
153
154        assert_eq!(deps.len(), 1);
155        assert!(deps.contains_key(&ResourceType::Agent));
156
157        let agents = &deps[&ResourceType::Agent];
158        assert_eq!(agents.len(), 1);
159        assert_eq!(agents[0].path, "agents/helper.md");
160    }
161
162    #[tokio::test]
163    async fn test_extract_nonexistent_file() {
164        let temp_dir = TempDir::new().unwrap();
165
166        let result = extract_transitive_deps(temp_dir.path(), "nonexistent.md", None).await;
167
168        assert!(result.is_err());
169    }
170}