use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use include_dir::{include_dir, Dir, DirEntry, File};
use serde::{Deserialize, Serialize};
pub static PLUGIN_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/plugins/heal");
pub const INSTALL_MANIFEST: &str = ".heal-install.json";
pub const PLUGIN_DEST_REL: &str = ".claude/plugins/heal";
#[must_use]
pub fn plugin_dest(project: &Path) -> PathBuf {
project.join(PLUGIN_DEST_REL)
}
pub const INSTALL_SOURCE_BUNDLED: &str = "bundled";
#[derive(Debug, Clone, Copy)]
pub enum ExtractMode {
InstallSafe,
InstallForce,
Update { force: bool },
}
#[derive(Debug, Default, Clone)]
pub struct ExtractStats {
pub added: Vec<String>,
pub updated: Vec<String>,
pub unchanged: Vec<String>,
pub skipped: Vec<String>,
pub user_modified: Vec<String>,
}
impl ExtractStats {
#[must_use]
pub fn summary(&self) -> ExtractSummary {
ExtractSummary {
added: self.added.len(),
updated: self.updated.len(),
unchanged: self.unchanged.len(),
skipped: self.skipped.len(),
user_modified: self.user_modified.len(),
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ExtractSummary {
pub added: usize,
pub updated: usize,
pub unchanged: usize,
pub skipped: usize,
pub user_modified: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallManifest {
pub heal_version: String,
pub installed_at: DateTime<Utc>,
pub source: String,
pub assets: BTreeMap<String, String>,
}
impl InstallManifest {
fn new(version: String, now: DateTime<Utc>) -> Self {
Self {
heal_version: version,
installed_at: now,
source: INSTALL_SOURCE_BUNDLED.to_string(),
assets: BTreeMap::new(),
}
}
pub fn load(plugin_root: &Path) -> Option<Self> {
let path = plugin_root.join(INSTALL_MANIFEST);
let body = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&body).ok()
}
fn save(&self, plugin_root: &Path) -> Result<()> {
let path = plugin_root.join(INSTALL_MANIFEST);
let body = serde_json::to_string_pretty(self)
.expect("InstallManifest serialization is infallible");
std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))
}
}
#[must_use]
pub fn bundled_version() -> Option<String> {
let file = PLUGIN_DIR.get_file("plugin.json")?;
let body = std::str::from_utf8(file.contents()).ok()?;
let v: serde_json::Value = serde_json::from_str(body).ok()?;
v.get("version")?.as_str().map(str::to_string)
}
pub fn extract(dest: &Path, mode: ExtractMode) -> Result<(ExtractStats, InstallManifest)> {
std::fs::create_dir_all(dest)
.with_context(|| format!("creating plugin dest dir {}", dest.display()))?;
let prior = InstallManifest::load(dest);
let version = bundled_version().unwrap_or_else(|| "unknown".to_string());
let mut manifest = InstallManifest::new(version.clone(), Utc::now());
let install_meta = SkillInstallMeta {
version,
source: INSTALL_SOURCE_BUNDLED.to_string(),
};
let mut stats = ExtractStats::default();
walk(
&PLUGIN_DIR,
dest,
mode,
prior.as_ref(),
&install_meta,
&mut stats,
&mut manifest,
)?;
manifest.save(dest)?;
Ok((stats, manifest))
}
fn walk(
dir: &Dir<'_>,
dest: &Path,
mode: ExtractMode,
prior: Option<&InstallManifest>,
meta: &SkillInstallMeta,
stats: &mut ExtractStats,
manifest: &mut InstallManifest,
) -> Result<()> {
for entry in dir.entries() {
let rel_path = entry.path();
let target = dest.join(rel_path);
match entry {
DirEntry::Dir(child) => {
std::fs::create_dir_all(&target)
.with_context(|| format!("mkdir {}", target.display()))?;
walk(child, dest, mode, prior, meta, stats, manifest)?;
}
DirEntry::File(file) => {
handle_file(file, &target, rel_path, mode, prior, meta, stats, manifest)?;
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn handle_file(
file: &File<'_>,
target: &Path,
rel_path: &Path,
mode: ExtractMode,
prior: Option<&InstallManifest>,
meta: &SkillInstallMeta,
stats: &mut ExtractStats,
manifest: &mut InstallManifest,
) -> Result<()> {
let rel_key = relative_key(rel_path);
let body = canonical_bytes(file, rel_path, meta);
let new_fp = fingerprint(&body);
manifest.assets.insert(rel_key.clone(), new_fp.clone());
if !target.exists() {
write_asset(target, &body)?;
stats.added.push(rel_key);
return Ok(());
}
match mode {
ExtractMode::InstallSafe => {
stats.skipped.push(rel_key);
return Ok(());
}
ExtractMode::InstallForce => {
let pre_fp = fingerprint(&std::fs::read(target).unwrap_or_default());
write_asset(target, &body)?;
classify(stats, &pre_fp, &new_fp, rel_key);
return Ok(());
}
ExtractMode::Update { force } => {
let pre_fp = fingerprint(&std::fs::read(target).unwrap_or_default());
let prior_fp = prior.and_then(|m| m.assets.get(&rel_key)).cloned();
let drifted = prior_fp.map_or(pre_fp != new_fp, |p| pre_fp != p);
if drifted && !force {
stats.user_modified.push(rel_key);
return Ok(());
}
write_asset(target, &body)?;
classify(stats, &pre_fp, &new_fp, rel_key);
}
}
Ok(())
}
fn classify(stats: &mut ExtractStats, pre_fp: &str, new_fp: &str, rel_key: String) {
if pre_fp == new_fp {
stats.unchanged.push(rel_key);
} else {
stats.updated.push(rel_key);
}
}
fn write_asset(target: &Path, body: &[u8]) -> Result<()> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
std::fs::write(target, body).with_context(|| format!("writing {}", target.display()))?;
#[cfg(unix)]
if target.extension().is_some_and(|ext| ext == "sh") {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(target)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(target, perms)?;
}
Ok(())
}
fn canonical_bytes(file: &File<'_>, rel_path: &Path, meta: &SkillInstallMeta) -> Vec<u8> {
let raw = file.contents();
if rel_path.file_name().is_some_and(|n| n == "SKILL.md") {
if let Ok(text) = std::str::from_utf8(raw) {
return inject_skill_metadata(text, meta).into_bytes();
}
}
raw.to_vec()
}
#[derive(Debug, Clone)]
struct SkillInstallMeta {
version: String,
source: String,
}
fn inject_skill_metadata(body: &str, meta: &SkillInstallMeta) -> String {
if !body.starts_with("---\n") {
return body.to_string();
}
let after_open = &body[4..];
let Some(close_offset) = after_open.find("\n---\n") else {
return body.to_string();
};
let frontmatter = &after_open[..close_offset];
let rest = &after_open[close_offset + 5..];
let mut kept_lines: Vec<&str> = Vec::new();
let mut in_metadata = false;
for line in frontmatter.lines() {
if in_metadata {
if line.starts_with(' ') || line.is_empty() {
continue;
}
in_metadata = false;
}
if line.trim_start().starts_with("metadata:") {
in_metadata = true;
continue;
}
kept_lines.push(line);
}
let mut out = String::with_capacity(body.len() + 200);
out.push_str("---\n");
for line in &kept_lines {
out.push_str(line);
out.push('\n');
}
out.push_str("metadata:\n");
let _ = writeln!(out, " heal-version: {}", meta.version);
let _ = writeln!(out, " heal-source: {}", meta.source);
out.push_str("---\n");
out.push_str(rest);
out
}
pub(crate) fn fingerprint(bytes: &[u8]) -> String {
crate::core::hash::fnv1a_hex(crate::core::hash::fnv1a_64(bytes))
}
fn relative_key(p: &Path) -> String {
p.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixed_meta() -> SkillInstallMeta {
SkillInstallMeta {
version: "0.1.0".into(),
source: "bundled".into(),
}
}
#[test]
fn bundled_version_reads_plugin_json() {
assert_eq!(bundled_version().as_deref(), Some("0.2.1"));
}
#[test]
fn fingerprint_is_deterministic() {
let a = fingerprint(b"hello");
let b = fingerprint(b"hello");
assert_eq!(a, b);
assert_ne!(a, fingerprint(b"hellx"));
}
#[test]
fn inject_metadata_inserts_block() {
let body = "---\nname: x\ndescription: y\n---\n\n# x\n";
let out = inject_skill_metadata(body, &fixed_meta());
assert!(out.contains("metadata:"));
assert!(out.contains("heal-version: 0.1.0"));
assert!(out.contains("heal-source: bundled"));
assert!(out.contains("# x"));
}
#[test]
fn inject_metadata_is_deterministic() {
let body = "---\nname: x\n---\n\nbody\n";
assert_eq!(
inject_skill_metadata(body, &fixed_meta()),
inject_skill_metadata(body, &fixed_meta())
);
}
#[test]
fn inject_metadata_is_idempotent() {
let body = "---\nname: x\n---\n\nbody\n";
let once = inject_skill_metadata(body, &fixed_meta());
let twice = inject_skill_metadata(&once, &fixed_meta());
assert_eq!(once, twice, "second injection must not duplicate metadata");
}
#[test]
fn extract_install_safe_preserves_existing_files() {
let dir = TempDir::new().unwrap();
let dest = dir.path();
let (stats1, _) = extract(dest, ExtractMode::InstallSafe).unwrap();
assert!(stats1.added.iter().any(|p| p == "plugin.json"));
let target = dest.join("hooks/claude-stop.sh");
std::fs::write(&target, "#!/bin/sh\n# user edit\n").unwrap();
let (stats2, _) = extract(dest, ExtractMode::InstallSafe).unwrap();
assert!(stats2.skipped.iter().any(|p| p == "hooks/claude-stop.sh"));
let body = std::fs::read_to_string(&target).unwrap();
assert!(body.contains("user edit"));
}
#[test]
fn extract_update_skips_user_modified_without_force() {
let dir = TempDir::new().unwrap();
let dest = dir.path();
extract(dest, ExtractMode::InstallSafe).unwrap();
let target = dest.join("hooks/claude-stop.sh");
std::fs::write(&target, "#!/bin/sh\n# user edit\n").unwrap();
let (stats, _) = extract(dest, ExtractMode::Update { force: false }).unwrap();
assert!(stats
.user_modified
.iter()
.any(|p| p == "hooks/claude-stop.sh"));
let body = std::fs::read_to_string(&target).unwrap();
assert!(body.contains("user edit"));
}
#[test]
fn extract_update_force_overwrites_user_edits() {
let dir = TempDir::new().unwrap();
let dest = dir.path();
extract(dest, ExtractMode::InstallSafe).unwrap();
let target = dest.join("hooks/claude-stop.sh");
std::fs::write(&target, "#!/bin/sh\n# user edit\n").unwrap();
let (stats, _) = extract(dest, ExtractMode::Update { force: true }).unwrap();
assert!(stats.updated.iter().any(|p| p == "hooks/claude-stop.sh"));
let body = std::fs::read_to_string(&target).unwrap();
assert!(!body.contains("user edit"));
}
#[test]
fn install_manifest_records_version_and_assets() {
let dir = TempDir::new().unwrap();
let dest = dir.path();
let (_, manifest) = extract(dest, ExtractMode::InstallSafe).unwrap();
assert_eq!(manifest.heal_version, bundled_version().unwrap());
assert_eq!(manifest.source, "bundled");
assert!(manifest.assets.contains_key("plugin.json"));
let loaded = InstallManifest::load(dest).unwrap();
assert_eq!(loaded, manifest);
}
#[test]
fn skill_md_install_carries_frontmatter_metadata() {
let dir = TempDir::new().unwrap();
let dest = dir.path();
extract(dest, ExtractMode::InstallSafe).unwrap();
let body = std::fs::read_to_string(dest.join("skills/heal-code-check/SKILL.md")).unwrap();
assert!(body.contains("metadata:"));
assert!(body.contains(&format!("heal-version: {}", bundled_version().unwrap())));
}
}