agpm_cli/templating/
content.rs

1//! Content extraction from resource files for template rendering.
2//!
3//! This module handles reading and processing resource files (Markdown, JSON, etc.)
4//! to extract content for template rendering.
5
6use crate::core::file_error::{FileOperation, FileResultExt};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10/// Helper trait for content extraction methods.
11///
12/// This trait is implemented on `TemplateContextBuilder` to provide
13/// content extraction functionality.
14pub(crate) trait ContentExtractor {
15    /// Get the cache instance
16    fn cache(&self) -> &Arc<crate::cache::Cache>;
17
18    /// Get the project directory
19    fn project_dir(&self) -> &PathBuf;
20
21    /// Extract and process content from a resource file.
22    ///
23    /// Reads the source file and processes it based on file type:
24    /// - Markdown (.md): Strips YAML frontmatter, returns content only
25    /// - JSON (.json): Removes metadata fields like `dependencies`
26    /// - Other files: Returns raw content
27    ///
28    /// # Arguments
29    ///
30    /// * `resource` - The locked resource to extract content from
31    ///
32    /// # Returns
33    ///
34    /// Returns `Some((content, has_templating))` if extraction succeeded, `None` on error (with warning logged)
35    /// For markdown files, `has_templating` indicates if `agpm.templating: true` is set in frontmatter
36    /// For non-markdown files, `has_templating` is always `false`
37    async fn extract_content(
38        &self,
39        resource: &crate::lockfile::LockedResource,
40    ) -> Option<(String, bool)> {
41        tracing::debug!(
42            "Attempting to extract content for resource '{}' (type: {:?})",
43            resource.name,
44            resource.resource_type
45        );
46
47        // Determine source path
48        let source_path = if let Some(source_name) = &resource.source {
49            let url = resource.url.as_ref()?;
50
51            // Check if this is a local directory source
52            let is_local_source = resource.is_local();
53
54            tracing::debug!(
55                "Resource '{}': source='{}', url='{}', is_local={}",
56                resource.name,
57                source_name,
58                url,
59                is_local_source
60            );
61
62            if is_local_source {
63                // Local directory source - use URL as path directly
64                let path = std::path::PathBuf::from(url).join(&resource.path);
65                tracing::debug!("Using local source path: {}", path.display());
66                path
67            } else {
68                // Git-based source - get worktree path
69                let sha = resource.resolved_commit.as_deref()?;
70
71                tracing::debug!(
72                    "Resource '{}': Getting worktree for SHA {}...",
73                    resource.name,
74                    &sha[..8.min(sha.len())]
75                );
76
77                // Use centralized worktree path construction
78                let worktree_dir = match self.cache().get_worktree_path(url, sha) {
79                    Ok(path) => {
80                        tracing::debug!("Worktree path: {}", path.display());
81                        path
82                    }
83                    Err(e) => {
84                        tracing::warn!(
85                            "Failed to construct worktree path for resource '{}': {}",
86                            resource.name,
87                            e
88                        );
89                        return None;
90                    }
91                };
92
93                let full_path = worktree_dir.join(&resource.path);
94                tracing::debug!(
95                    "Full source path for '{}': {} (worktree exists: {})",
96                    resource.name,
97                    full_path.display(),
98                    worktree_dir.exists()
99                );
100                full_path
101            }
102        } else {
103            // Local file - path is relative to project or absolute
104            let local_path = std::path::Path::new(&resource.path);
105            let resolved_path = if local_path.is_absolute() {
106                local_path.to_path_buf()
107            } else {
108                self.project_dir().join(local_path)
109            };
110
111            tracing::debug!(
112                "Resource '{}': Using local file path: {}",
113                resource.name,
114                resolved_path.display()
115            );
116
117            resolved_path
118        };
119
120        // Read file content
121        let content = match tokio::fs::read_to_string(&source_path).await.with_file_context(
122            FileOperation::Read,
123            &source_path,
124            format!("reading content for resource '{}'", resource.name),
125            "content_filter",
126        ) {
127            Ok(c) => c,
128            Err(e) => {
129                tracing::warn!(
130                    "Failed to read content for resource '{}' from {}: {}",
131                    resource.name,
132                    source_path.display(),
133                    e
134                );
135                return None;
136            }
137        };
138
139        // Process based on file type
140        let processed_content = if resource.path.ends_with(".md") {
141            // Markdown: Keep frontmatter for accurate line numbers, but protect non-templated content
142            // CRITICAL: Use parse_with_templating() to handle template syntax in frontmatter (e.g., {% if %})
143            // This ensures conditional dependencies and other template logic in YAML is processed
144            // before we check the templating flag
145            match crate::markdown::MarkdownDocument::parse_with_templating(
146                &content,
147                Some(resource.variant_inputs.json()),
148                Some(std::path::Path::new(&resource.path)),
149            ) {
150                Ok(doc) => {
151                    let templating_enabled = is_markdown_templating_enabled(doc.metadata.as_ref());
152
153                    if resource.name.contains("frontend-engineer") {
154                        let has_template_syntax = doc.content.contains("{{")
155                            || doc.content.contains("{%")
156                            || doc.content.contains("{#");
157                        tracing::warn!(
158                            "[EXTRACT_CONTENT] Resource '{}': templating_enabled={}, has_template_syntax={}",
159                            resource.name,
160                            templating_enabled,
161                            has_template_syntax
162                        );
163                    }
164
165                    // Return content with frontmatter stripped, and templating flag
166                    // Note: We no longer wrap non-templated content in guards because
167                    // multi-pass rendering has been removed. Dependencies are rendered
168                    // once with their own context and embedded as-is.
169                    (doc.content, templating_enabled)
170                }
171                Err(e) => {
172                    tracing::warn!(
173                        "Failed to parse markdown for resource '{}': {}. Using raw content.",
174                        resource.name,
175                        e
176                    );
177                    (content, false)
178                }
179            }
180        } else if resource.path.ends_with(".json") {
181            // JSON: parse and remove metadata fields (no templating for JSON)
182            match serde_json::from_str::<serde_json::Value>(&content) {
183                Ok(mut json) => {
184                    if let Some(obj) = json.as_object_mut() {
185                        // Remove metadata fields that shouldn't be in embedded content
186                        obj.remove("dependencies");
187                    }
188                    (serde_json::to_string_pretty(&json).unwrap_or(content), false)
189                }
190                Err(e) => {
191                    tracing::warn!(
192                        "Failed to parse JSON for resource '{}': {}. Using raw content.",
193                        resource.name,
194                        e
195                    );
196                    (content, false)
197                }
198            }
199        } else {
200            // Other files: use raw content (no templating)
201            (content, false)
202        };
203
204        Some(processed_content)
205    }
206}
207
208/// Determine whether templating is explicitly enabled in Markdown frontmatter.
209pub(crate) fn is_markdown_templating_enabled(
210    metadata: Option<&crate::markdown::MarkdownMetadata>,
211) -> bool {
212    metadata
213        .and_then(|md| md.extra.get("agpm"))
214        .and_then(|agpm| agpm.as_object())
215        .and_then(|agpm_obj| agpm_obj.get("templating"))
216        .and_then(|value| value.as_bool())
217        .unwrap_or(false)
218}