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}