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/// Sentinel markers used to guard non-templated dependency content.
11/// Content enclosed between these markers should be treated as literal text
12/// and never passed through the templating engine.
13pub(crate) const NON_TEMPLATED_LITERAL_GUARD_START: &str = "__AGPM_LITERAL_RAW_START__";
14pub(crate) const NON_TEMPLATED_LITERAL_GUARD_END: &str = "__AGPM_LITERAL_RAW_END__";
15
16/// Helper trait for content extraction methods.
17///
18/// This trait is implemented on `TemplateContextBuilder` to provide
19/// content extraction functionality.
20pub(crate) trait ContentExtractor {
21    /// Get the cache instance
22    fn cache(&self) -> &Arc<crate::cache::Cache>;
23
24    /// Get the project directory
25    fn project_dir(&self) -> &PathBuf;
26
27    /// Extract and process content from a resource file.
28    ///
29    /// Reads the source file and processes it based on file type:
30    /// - Markdown (.md): Strips YAML frontmatter, returns content only
31    /// - JSON (.json): Removes metadata fields like `dependencies`
32    /// - Other files: Returns raw content
33    ///
34    /// # Arguments
35    ///
36    /// * `resource` - The locked resource to extract content from
37    ///
38    /// # Returns
39    ///
40    /// Returns `Some(content)` if extraction succeeded, `None` on error (with warning logged)
41    async fn extract_content(&self, resource: &crate::lockfile::LockedResource) -> Option<String> {
42        tracing::debug!(
43            "Attempting to extract content for resource '{}' (type: {:?})",
44            resource.name,
45            resource.resource_type
46        );
47
48        // Determine source path
49        let source_path = if let Some(source_name) = &resource.source {
50            let url = resource.url.as_ref()?;
51
52            // Check if this is a local directory source
53            let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
54
55            tracing::debug!(
56                "Resource '{}': source='{}', url='{}', is_local={}",
57                resource.name,
58                source_name,
59                url,
60                is_local_source
61            );
62
63            if is_local_source {
64                // Local directory source - use URL as path directly
65                let path = std::path::PathBuf::from(url).join(&resource.path);
66                tracing::debug!("Using local source path: {}", path.display());
67                path
68            } else {
69                // Git-based source - get worktree path
70                let sha = resource.resolved_commit.as_deref()?;
71
72                tracing::debug!(
73                    "Resource '{}': Getting worktree for SHA {}...",
74                    resource.name,
75                    &sha[..8.min(sha.len())]
76                );
77
78                // Use centralized worktree path construction
79                let worktree_dir = match self.cache().get_worktree_path(url, sha) {
80                    Ok(path) => {
81                        tracing::debug!("Worktree path: {}", path.display());
82                        path
83                    }
84                    Err(e) => {
85                        tracing::warn!(
86                            "Failed to construct worktree path for resource '{}': {}",
87                            resource.name,
88                            e
89                        );
90                        return None;
91                    }
92                };
93
94                let full_path = worktree_dir.join(&resource.path);
95                tracing::debug!(
96                    "Full source path for '{}': {} (worktree exists: {})",
97                    resource.name,
98                    full_path.display(),
99                    worktree_dir.exists()
100                );
101                full_path
102            }
103        } else {
104            // Local file - path is relative to project or absolute
105            let local_path = std::path::Path::new(&resource.path);
106            let resolved_path = if local_path.is_absolute() {
107                local_path.to_path_buf()
108            } else {
109                self.project_dir().join(local_path)
110            };
111
112            tracing::debug!(
113                "Resource '{}': Using local file path: {}",
114                resource.name,
115                resolved_path.display()
116            );
117
118            resolved_path
119        };
120
121        // Read file content
122        let content = match tokio::fs::read_to_string(&source_path).await.with_file_context(
123            FileOperation::Read,
124            &source_path,
125            format!("reading content for resource '{}'", resource.name),
126            "content_filter",
127        ) {
128            Ok(c) => c,
129            Err(e) => {
130                tracing::warn!(
131                    "Failed to read content for resource '{}' from {}: {}",
132                    resource.name,
133                    source_path.display(),
134                    e
135                );
136                return None;
137            }
138        };
139
140        // Process based on file type
141        let processed_content = if resource.path.ends_with(".md") {
142            // Markdown: strip frontmatter and guard non-templated content that contains template syntax
143            match crate::markdown::MarkdownDocument::parse(&content) {
144                Ok(doc) => {
145                    let templating_enabled = is_markdown_templating_enabled(doc.metadata.as_ref());
146                    let mut stripped_content = doc.content;
147
148                    if !templating_enabled && content_contains_template_syntax(&stripped_content) {
149                        tracing::debug!(
150                            "Protecting non-templated markdown content for '{}'",
151                            resource.name
152                        );
153                        stripped_content = wrap_content_in_literal_guard(stripped_content);
154                    }
155
156                    stripped_content
157                }
158                Err(e) => {
159                    tracing::warn!(
160                        "Failed to parse markdown for resource '{}': {}. Using raw content.",
161                        resource.name,
162                        e
163                    );
164                    content
165                }
166            }
167        } else if resource.path.ends_with(".json") {
168            // JSON: parse and remove metadata fields
169            match serde_json::from_str::<serde_json::Value>(&content) {
170                Ok(mut json) => {
171                    if let Some(obj) = json.as_object_mut() {
172                        // Remove metadata fields that shouldn't be in embedded content
173                        obj.remove("dependencies");
174                    }
175                    serde_json::to_string_pretty(&json).unwrap_or(content)
176                }
177                Err(e) => {
178                    tracing::warn!(
179                        "Failed to parse JSON for resource '{}': {}. Using raw content.",
180                        resource.name,
181                        e
182                    );
183                    content
184                }
185            }
186        } else {
187            // Other files: use raw content
188            content
189        };
190
191        Some(processed_content)
192    }
193}
194
195/// Determine whether templating is explicitly enabled in Markdown frontmatter.
196pub(crate) fn is_markdown_templating_enabled(
197    metadata: Option<&crate::markdown::MarkdownMetadata>,
198) -> bool {
199    metadata
200        .and_then(|md| md.extra.get("agpm"))
201        .and_then(|agpm| agpm.as_object())
202        .and_then(|agpm_obj| agpm_obj.get("templating"))
203        .and_then(|value| value.as_bool())
204        .unwrap_or(false)
205}
206
207/// Detect if content contains Tera template syntax markers.
208pub(crate) fn content_contains_template_syntax(content: &str) -> bool {
209    content.contains("{{") || content.contains("{%") || content.contains("{#")
210}
211
212/// Wrap non-templated content in a literal fence so it renders safely without being evaluated.
213pub(crate) fn wrap_content_in_literal_guard(content: String) -> String {
214    let mut wrapped = String::with_capacity(
215        content.len()
216            + NON_TEMPLATED_LITERAL_GUARD_START.len()
217            + NON_TEMPLATED_LITERAL_GUARD_END.len()
218            + 2, // newline separators
219    );
220
221    wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
222    wrapped.push('\n');
223    wrapped.push_str(&content);
224    if !content.ends_with('\n') {
225        wrapped.push('\n');
226    }
227    wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_END);
228
229    wrapped
230}