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 std::path::PathBuf;
7use std::sync::Arc;
8
9/// Sentinel markers used to guard non-templated dependency content.
10/// Content enclosed between these markers should be treated as literal text
11/// and never passed through the templating engine.
12pub(crate) const NON_TEMPLATED_LITERAL_GUARD_START: &str = "__AGPM_LITERAL_RAW_START__";
13pub(crate) const NON_TEMPLATED_LITERAL_GUARD_END: &str = "__AGPM_LITERAL_RAW_END__";
14
15/// Helper trait for content extraction methods.
16///
17/// This trait is implemented on `TemplateContextBuilder` to provide
18/// content extraction functionality.
19pub(crate) trait ContentExtractor {
20    /// Get the cache instance
21    fn cache(&self) -> &Arc<crate::cache::Cache>;
22
23    /// Get the project directory
24    fn project_dir(&self) -> &PathBuf;
25
26    /// Extract and process content from a resource file.
27    ///
28    /// Reads the source file and processes it based on file type:
29    /// - Markdown (.md): Strips YAML frontmatter, returns content only
30    /// - JSON (.json): Removes metadata fields like `dependencies`
31    /// - Other files: Returns raw content
32    ///
33    /// # Arguments
34    ///
35    /// * `resource` - The locked resource to extract content from
36    ///
37    /// # Returns
38    ///
39    /// Returns `Some(content)` if extraction succeeded, `None` on error (with warning logged)
40    async fn extract_content(&self, resource: &crate::lockfile::LockedResource) -> Option<String> {
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.resolved_commit.as_deref().is_none_or(str::is_empty);
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 {
122            Ok(c) => c,
123            Err(e) => {
124                tracing::warn!(
125                    "Failed to read content for resource '{}' from {}: {}",
126                    resource.name,
127                    source_path.display(),
128                    e
129                );
130                return None;
131            }
132        };
133
134        // Process based on file type
135        let processed_content = if resource.path.ends_with(".md") {
136            // Markdown: strip frontmatter and guard non-templated content that contains template syntax
137            match crate::markdown::MarkdownDocument::parse(&content) {
138                Ok(doc) => {
139                    let templating_enabled = is_markdown_templating_enabled(doc.metadata.as_ref());
140                    let mut stripped_content = doc.content;
141
142                    if !templating_enabled && content_contains_template_syntax(&stripped_content) {
143                        tracing::debug!(
144                            "Protecting non-templated markdown content for '{}'",
145                            resource.name
146                        );
147                        stripped_content = wrap_content_in_literal_guard(stripped_content);
148                    }
149
150                    stripped_content
151                }
152                Err(e) => {
153                    tracing::warn!(
154                        "Failed to parse markdown for resource '{}': {}. Using raw content.",
155                        resource.name,
156                        e
157                    );
158                    content
159                }
160            }
161        } else if resource.path.ends_with(".json") {
162            // JSON: parse and remove metadata fields
163            match serde_json::from_str::<serde_json::Value>(&content) {
164                Ok(mut json) => {
165                    if let Some(obj) = json.as_object_mut() {
166                        // Remove metadata fields that shouldn't be in embedded content
167                        obj.remove("dependencies");
168                    }
169                    serde_json::to_string_pretty(&json).unwrap_or(content)
170                }
171                Err(e) => {
172                    tracing::warn!(
173                        "Failed to parse JSON for resource '{}': {}. Using raw content.",
174                        resource.name,
175                        e
176                    );
177                    content
178                }
179            }
180        } else {
181            // Other files: use raw content
182            content
183        };
184
185        Some(processed_content)
186    }
187}
188
189/// Determine whether templating is explicitly enabled in Markdown frontmatter.
190pub(crate) fn is_markdown_templating_enabled(
191    metadata: Option<&crate::markdown::MarkdownMetadata>,
192) -> bool {
193    metadata
194        .and_then(|md| md.extra.get("agpm"))
195        .and_then(|agpm| agpm.as_object())
196        .and_then(|agpm_obj| agpm_obj.get("templating"))
197        .and_then(|value| value.as_bool())
198        .unwrap_or(false)
199}
200
201/// Detect if content contains Tera template syntax markers.
202pub(crate) fn content_contains_template_syntax(content: &str) -> bool {
203    content.contains("{{") || content.contains("{%") || content.contains("{#")
204}
205
206/// Wrap non-templated content in a literal fence so it renders safely without being evaluated.
207pub(crate) fn wrap_content_in_literal_guard(content: String) -> String {
208    let mut wrapped = String::with_capacity(
209        content.len()
210            + NON_TEMPLATED_LITERAL_GUARD_START.len()
211            + NON_TEMPLATED_LITERAL_GUARD_END.len()
212            + 2, // newline separators
213    );
214
215    wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
216    wrapped.push('\n');
217    wrapped.push_str(&content);
218    if !content.ends_with('\n') {
219        wrapped.push('\n');
220    }
221    wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_END);
222
223    wrapped
224}