use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use crate::core::file_error::{FileOperation, FileResultExt};
use crate::installer::context::InstallContext;
use crate::lockfile::LockedResource;
use crate::markdown::MarkdownFile;
use crate::templating::RenderingMetadata;
use crate::utils::fs::{atomic_write, ensure_dir};
type SkipCheckResult =
(bool, String, Option<String>, crate::manifest::patches::AppliedPatches, Option<u64>);
pub async fn read_source_content(
entry: &LockedResource,
context: &InstallContext<'_>,
) -> Result<String> {
if let Some(source_name) = &entry.source {
let url = entry
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Resource {} has no URL", entry.name))?;
let is_local_source = entry.is_local();
let cache_dir = if is_local_source {
PathBuf::from(url)
} else {
let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"Resource {} missing resolved commit SHA. Run 'agpm update' to regenerate lockfile.",
entry.name
)
})?;
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!(
"Invalid SHA '{}' for resource {}. Expected 40 hex characters.",
sha,
entry.name
));
}
let mut cache_dir = context
.cache
.get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
.await?;
if context.force_refresh {
let _ = context.cache.cleanup_worktree(&cache_dir).await;
cache_dir = context
.cache
.get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
.await?;
}
cache_dir
};
let source_path = cache_dir.join(&entry.path);
crate::utils::fs::read_text_file_with_retry(&source_path).await
} else {
let source_path = {
let candidate = Path::new(&entry.path);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
context.project_dir.join(candidate)
}
};
if !source_path.exists() {
return Err(anyhow::anyhow!(
"Local file '{}' not found. Expected at: {}",
entry.path,
source_path.display()
));
}
tokio::fs::read_to_string(&source_path)
.await
.with_file_context(
FileOperation::Read,
&source_path,
"reading resource file",
"installer_resource",
)
.map_err(Into::into)
}
}
pub fn validate_markdown_content(content: &str) -> Result<()> {
MarkdownFile::parse(content)?;
Ok(())
}
pub fn apply_resource_patches(
content: &str,
entry: &LockedResource,
context: &InstallContext<'_>,
) -> Result<(String, crate::manifest::patches::AppliedPatches)> {
let empty_patches = std::collections::BTreeMap::new();
if context.project_patches.is_some() || context.private_patches.is_some() {
use crate::manifest::patches::apply_patches_to_content_with_origin;
let resource_type = entry.resource_type.to_plural();
let lookup_name = entry.lookup_name();
tracing::debug!(
"Installer patch lookup: resource_type={}, lookup_name={}, name={}, manifest_alias={:?}",
resource_type,
lookup_name,
entry.name,
entry.manifest_alias
);
let project_patch_data = context
.project_patches
.and_then(|patches| patches.get(resource_type, lookup_name))
.unwrap_or(&empty_patches);
tracing::debug!("Found {} project patches for {}", project_patch_data.len(), lookup_name);
let private_patch_data = context
.private_patches
.and_then(|patches| patches.get(resource_type, lookup_name))
.unwrap_or(&empty_patches);
let file_path = entry.installed_at.as_str();
apply_patches_to_content_with_origin(
content,
file_path,
project_patch_data,
private_patch_data,
)
.with_context(|| format!("Failed to apply patches to resource {}", entry.name))
} else {
Ok((content.to_string(), crate::manifest::patches::AppliedPatches::default()))
}
}
pub async fn render_resource_content(
content: &str,
entry: &LockedResource,
context: &InstallContext<'_>,
) -> Result<(String, bool, Option<String>)> {
if !entry.path.ends_with(".md") {
tracing::debug!("Not a markdown file: {}", entry.path);
return Ok((content.to_string(), false, None));
}
let frontmatter_parser = crate::markdown::frontmatter::FrontmatterParser::new();
let raw_frontmatter = frontmatter_parser.extract_raw_frontmatter(content);
let boundaries = frontmatter_parser.get_frontmatter_boundaries(content);
let Some(raw_fm) = raw_frontmatter else {
return Ok((content.to_string(), false, None));
};
let template_context_builder = &context.template_context_builder;
let resource_id = crate::lockfile::ResourceId::new(
entry.name.clone(),
entry.source.clone(),
entry.tool.clone(),
entry.resource_type,
entry.variant_inputs.hash().to_string(),
);
let template_context = match template_context_builder
.build_context(&resource_id, entry.variant_inputs.json())
.await
{
Ok((ctx, _)) => ctx,
Err(e) => {
tracing::debug!(
"Failed to build template context for resource '{}', using original content: {}",
entry.name,
e
);
return Ok((content.to_string(), false, None));
}
};
let rendered_frontmatter = {
use crate::templating::TemplateRenderer;
let mut renderer = match TemplateRenderer::new(
true,
context.project_dir.to_path_buf(),
context.max_content_file_size,
) {
Ok(r) => r,
Err(e) => {
tracing::debug!(
"Failed to create template renderer for resource '{}', using original content: {}",
entry.name,
e
);
return Ok((content.to_string(), false, None));
}
};
match renderer.render_template(&raw_fm, &template_context, None) {
Ok(rendered) => rendered,
Err(e) => {
tracing::debug!(
"Failed to render frontmatter template for resource '{}', using original content: {}",
entry.name,
e
);
return Ok((content.to_string(), false, None));
}
}
};
let parsed: serde_json::Value = match serde_yaml::from_str(&rendered_frontmatter) {
Ok(p) => p,
Err(e) => {
tracing::debug!(
"Failed to parse rendered frontmatter for resource '{}' as valid YAML, using original content: {}",
entry.name,
e
);
return Ok((content.to_string(), false, None));
}
};
let templating_enabled = parsed
.get("agpm")
.and_then(|agpm| agpm.get("templating"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !templating_enabled {
if let Some(bounds) = boundaries {
let final_content =
frontmatter_parser.replace_frontmatter(content, &rendered_frontmatter, bounds);
Ok((final_content, false, None))
} else {
Ok((content.to_string(), false, None))
}
} else {
render_full_file(content, entry, context, template_context_builder).await
}
}
async fn render_full_file(
content: &str,
entry: &LockedResource,
context: &InstallContext<'_>,
template_context_builder: &crate::templating::TemplateContextBuilder,
) -> Result<(String, bool, Option<String>)> {
use crate::templating::TemplateRenderer;
let context_digest = template_context_builder.compute_context_digest().with_context(|| {
format!("Failed to compute context digest for resource '{}'", entry.name)
})?;
let resource_id = crate::lockfile::ResourceId::new(
entry.name.clone(),
entry.source.clone(),
entry.tool.clone(),
entry.resource_type,
entry.variant_inputs.hash().to_string(),
);
let (template_context, captured_context_checksum) = template_context_builder
.build_context(&resource_id, entry.variant_inputs.json())
.await
.with_context(|| {
format!("Failed to build template context for resource '{}'", entry.name)
})?;
if context.verbose {
let num_resources = template_context
.get("resources")
.and_then(|v| v.as_object())
.map(|o| o.len())
.unwrap_or(0);
let num_dependencies = template_context
.get("dependencies")
.and_then(|v| v.as_object())
.map(|o| o.len())
.unwrap_or(0);
tracing::info!("📝 Rendering template: {}", entry.path);
tracing::info!(
" Context: {} resources, {} dependencies",
num_resources,
num_dependencies
);
tracing::debug!(" Context digest: {}", context_digest);
}
let rendering_metadata = RenderingMetadata {
resource_name: entry.name.clone(),
resource_type: entry.resource_type,
dependency_chain: vec![],
source_path: Some(entry.path.clone().into()),
depth: 0,
};
let mut renderer = TemplateRenderer::new(
true,
context.project_dir.to_path_buf(),
context.max_content_file_size,
)
.with_context(|| "Failed to create template renderer")?;
let rendered_content = renderer
.render_template(content, &template_context, Some(&rendering_metadata))
.map_err(|e| {
tracing::error!("Template rendering failed for resource '{}': {}", entry.name, e);
anyhow::Error::from(e)
})?;
if context.verbose {
let size_bytes = rendered_content.len();
let size_str = if size_bytes < 1024 {
format!("{} B", size_bytes)
} else if size_bytes < 1024 * 1024 {
format!("{:.1} KB", size_bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", size_bytes as f64 / (1024.0 * 1024.0))
};
let dest_path = if entry.installed_at.is_empty() {
context
.project_dir
.join(entry.resource_type.to_plural())
.join(format!("{}.md", entry.name))
} else {
context.project_dir.join(&entry.installed_at)
};
tracing::info!(" Output: {} ({})", dest_path.display(), size_str);
tracing::info!("✅ Template rendered successfully");
}
Ok((rendered_content, true, captured_context_checksum))
}
pub fn compute_file_checksum(content: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash = hasher.finalize();
format!("sha256:{}", hex::encode(hash))
}
fn inputs_match(entry: &LockedResource, old_entry: &LockedResource) -> bool {
entry.resolved_commit == old_entry.resolved_commit
&& entry.variant_inputs == old_entry.variant_inputs
&& entry.applied_patches == old_entry.applied_patches
&& entry.install == old_entry.install
}
pub(crate) fn should_skip_trusted(
entry: &LockedResource,
dest_path: &Path,
context: &InstallContext<'_>,
) -> Option<SkipCheckResult> {
if !context.trust_lockfile_checksums {
return None;
}
if context.force_refresh || entry.is_local() {
return None;
}
let old_lockfile = context.old_lockfile?;
let old_entry = old_lockfile.find_resource(&entry.name, &entry.resource_type)?;
if !inputs_match(entry, old_entry) {
return None;
}
let should_install = entry.install.unwrap_or(true);
if !should_install {
return None;
}
if !dest_path.exists() {
tracing::debug!(
"Trusted mode: file missing at {:?}, will reinstall {}",
dest_path,
entry.name
);
return None;
}
if let Ok(metadata) = std::fs::metadata(dest_path) {
if metadata.len() == 0 {
tracing::debug!(
"Trusted mode: file at {:?} is empty, will reinstall {}",
dest_path,
entry.name
);
return None;
}
}
tracing::debug!("⏭️ Trusted skip: {} (all inputs unchanged, file exists)", entry.name);
Some((
false, old_entry.checksum.clone(),
old_entry.context_checksum.clone(),
crate::manifest::patches::AppliedPatches::from_lockfile_patches(&old_entry.applied_patches),
old_entry.approximate_token_count, ))
}
pub fn should_skip_installation(
entry: &LockedResource,
dest_path: &Path,
existing_checksum: Option<&String>,
context: &InstallContext<'_>,
) -> Option<(String, Option<String>, crate::manifest::patches::AppliedPatches, Option<u64>)> {
if context.force_refresh || entry.is_local() {
return None;
}
let old_lockfile = context.old_lockfile?;
let old_entry = old_lockfile.find_resource(&entry.name, &entry.resource_type)?;
if inputs_match(entry, old_entry) && dest_path.exists() {
if existing_checksum == Some(&old_entry.checksum) {
tracing::debug!(
"⏭️ Skipping unchanged Git resource: {} (checksum matches)",
entry.name
);
return Some((
old_entry.checksum.clone(),
old_entry.context_checksum.clone(),
crate::manifest::patches::AppliedPatches::from_lockfile_patches(
&old_entry.applied_patches,
),
old_entry.approximate_token_count, ));
} else {
tracing::debug!(
"Checksum mismatch for {}: existing={:?}, expected={}",
entry.name,
existing_checksum,
old_entry.checksum
);
}
}
None
}
pub async fn write_resource_to_disk(
dest_path: &Path,
content: &str,
should_install: bool,
content_changed: bool,
_context: &InstallContext<'_>,
) -> Result<bool> {
if !should_install {
tracing::debug!("Skipping file write for content-only dependency (install=false)");
return Ok(false);
}
if !content_changed {
return Ok(false);
}
if let Some(parent) = dest_path.parent() {
ensure_dir(parent)?;
}
atomic_write(dest_path, content.as_bytes())
.with_context(|| format!("Failed to install resource to {}", dest_path.display()))?;
Ok(true)
}