agpm_cli/templating/
content.rs1use crate::core::file_error::{FileOperation, FileResultExt};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10pub(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
16pub(crate) trait ContentExtractor {
21 fn cache(&self) -> &Arc<crate::cache::Cache>;
23
24 fn project_dir(&self) -> &PathBuf;
26
27 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 let source_path = if let Some(source_name) = &resource.source {
50 let url = resource.url.as_ref()?;
51
52 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 let path = std::path::PathBuf::from(url).join(&resource.path);
66 tracing::debug!("Using local source path: {}", path.display());
67 path
68 } else {
69 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 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 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 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 let processed_content = if resource.path.ends_with(".md") {
142 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 match serde_json::from_str::<serde_json::Value>(&content) {
170 Ok(mut json) => {
171 if let Some(obj) = json.as_object_mut() {
172 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 content
189 };
190
191 Some(processed_content)
192 }
193}
194
195pub(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
207pub(crate) fn content_contains_template_syntax(content: &str) -> bool {
209 content.contains("{{") || content.contains("{%") || content.contains("{#")
210}
211
212pub(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, );
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}