use sqlx::SqlitePool;
use crate::context::rule_render::{RuleRenderInput, render_code_spec};
use crate::context::rule_source::RuleExample;
use crate::errors::CoreError;
use crate::observability::privacy::{redact_secretish_tokens, strip_private_tagged_regions};
use crate::packs::manifest::{PackManifest, PackRule};
use crate::packs::{
PACK_CONFIDENCE, PACK_ORIGIN, pack_rule_tag, pack_source_repo, pack_version_tag,
};
const PACK_SKILL_SOURCE: &str = "pack";
fn sanitize(input: &str) -> String {
redact_secretish_tokens(&strip_private_tagged_regions(input))
}
fn slugify(value: &str) -> String {
value
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn deterministic_suffix(pack_rule_id: &str) -> String {
use std::fmt::Write as _;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pack_rule_id.as_bytes());
let digest = hasher.finalize();
digest.iter().take(4).fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
fn local_skill_id(pack_id: &str, pack_rule_id: &str) -> String {
let pack_slug = slugify(pack_id);
let rule_leaf = pack_rule_id.rsplit('/').next().unwrap_or(pack_rule_id);
let rule_slug = slugify(rule_leaf);
format!(
"pack-{pack_slug}-{rule_slug}-{}",
deterministic_suffix(pack_rule_id)
)
}
fn effective_globs(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
let mut globs: Vec<String> = if rule.file_globs.is_empty() {
manifest
.target
.as_ref()
.map(|t| t.file_globs.clone())
.unwrap_or_default()
} else {
rule.file_globs.clone()
};
globs.retain(|g| !g.trim().is_empty());
globs
}
fn build_tags(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
let mut tags: Vec<String> = Vec::new();
tags.push(PACK_ORIGIN.to_owned());
tags.push(pack_version_tag(&manifest.id, &manifest.version));
tags.push(pack_rule_tag(&rule.id));
if let Some(lang) = manifest
.target
.as_ref()
.and_then(|t| t.languages.first())
.map(|l| l.trim().to_ascii_lowercase())
.filter(|l| !l.is_empty())
{
tags.push(lang);
}
if let Some(sev) = rule
.severity
.as_deref()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
{
tags.push(format!("severity:{sev}"));
}
for tag in &rule.tags {
let trimmed = tag.trim();
if !trimmed.is_empty() {
tags.push(trimmed.to_owned());
}
}
let mut seen = std::collections::HashSet::new();
tags.retain(|t| seen.insert(t.clone()));
tags
}
fn render_body(
skill_id: &str,
rule: &PackRule,
manifest: &PackManifest,
globs: &[String],
example: Option<&RuleExample>,
) -> String {
let source_repo = pack_source_repo(&manifest.id);
let description = rule
.body
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty())
.map(sanitize)
.unwrap_or_default();
let examples_slice = example.map(std::slice::from_ref);
let input = RuleRenderInput {
id: skill_id,
name: rule.title.trim(),
r#type: "review_standard",
confidence: PACK_CONFIDENCE,
origin: PACK_ORIGIN,
source_repo: Some(source_repo.as_str()),
file_patterns: globs,
description: &description,
trigger: None,
check_prompt: None,
examples: examples_slice,
};
render_code_spec(&input)
}
fn build_example(skill_id: &str, rule: &PackRule) -> Option<RuleExample> {
let ex = rule.examples.as_ref()?;
let bad = sanitize(ex.bad.as_deref().unwrap_or_default());
let good = sanitize(ex.good.as_deref().unwrap_or_default());
if bad.trim().is_empty() || good.trim().is_empty() {
return None;
}
let description = ex
.description
.as_deref()
.map(sanitize)
.map(|d| d.trim().to_owned())
.filter(|d| !d.is_empty());
Some(RuleExample {
id: format!(
"example-pack-{}",
crate::packs::manifest::manifest_sha256(skill_id.as_bytes())
),
skill_id: skill_id.to_owned(),
bad_code: bad.trim().to_owned(),
good_code: good.trim().to_owned(),
description,
source: PACK_ORIGIN.to_owned(),
})
}
fn build_skill_md(rule: &PackRule, tags: &[String], body: &str) -> String {
let mut md = String::new();
md.push_str("---\n");
md.push_str("type: review_standard\n");
md.push_str("engines: [claude]\n");
md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
md.push_str("origin: pack\n");
md.push_str("---\n\n");
md.push_str(&format!("# {}\n\n", rule.title.trim()));
md.push_str(body);
md.push('\n');
md
}
#[derive(Debug, Clone)]
pub struct InstalledPackRule {
pub skill_id: String,
pub pack_rule_id: String,
pub title: String,
pub file_patterns: Vec<String>,
pub tags: Vec<String>,
pub origin: String,
pub source_repo: String,
pub confidence: f64,
pub has_example: bool,
}
#[derive(Debug, Clone)]
pub struct InstallPackOutcome {
pub pack_id: String,
pub pack_version: String,
pub rules: Vec<InstalledPackRule>,
pub superseded_rule_ids: Vec<String>,
pub dry_run: bool,
}
pub async fn install_pack(
db: &SqlitePool,
manifest: &PackManifest,
dry_run: bool,
) -> Result<InstallPackOutcome, CoreError> {
let source_repo = pack_source_repo(&manifest.id);
struct Prepared {
skill_id: String,
pack_rule_id: String,
title: String,
globs: Vec<String>,
tags: Vec<String>,
body: String,
skill_md: String,
example: Option<RuleExample>,
}
let mut prepared: Vec<Prepared> = Vec::with_capacity(manifest.rules.len());
for rule in &manifest.rules {
if rule.title.trim().is_empty() {
return Err(CoreError::Validation(format!(
"pack '{}' has a rule with an empty title (id '{}')",
manifest.id, rule.id
)));
}
let skill_id = local_skill_id(&manifest.id, &rule.id);
let globs = effective_globs(rule, manifest);
let tags = build_tags(rule, manifest);
let example = build_example(&skill_id, rule);
let body = render_body(&skill_id, rule, manifest, &globs, example.as_ref());
let skill_md = build_skill_md(rule, &tags, &body);
prepared.push(Prepared {
skill_id,
pack_rule_id: rule.id.clone(),
title: rule.title.trim().to_owned(),
globs,
tags,
body,
skill_md,
example,
});
}
let installed: Vec<InstalledPackRule> = prepared
.iter()
.map(|p| InstalledPackRule {
skill_id: p.skill_id.clone(),
pack_rule_id: p.pack_rule_id.clone(),
title: p.title.clone(),
file_patterns: p.globs.clone(),
tags: p.tags.clone(),
origin: PACK_ORIGIN.to_owned(),
source_repo: source_repo.clone(),
confidence: PACK_CONFIDENCE,
has_example: p.example.is_some(),
})
.collect();
if dry_run {
return Ok(InstallPackOutcome {
pack_id: manifest.id.clone(),
pack_version: manifest.version.clone(),
rules: installed,
superseded_rule_ids: Vec::new(),
dry_run: true,
});
}
let base_dir = crate::skill_fs::skills_base_dir()
.map_err(CoreError::Internal)?
.join(PACK_SKILL_SOURCE);
std::fs::create_dir_all(&base_dir)
.map_err(|e| CoreError::Internal(format!("failed to create pack skills dir: {e}")))?;
let canonical_base = base_dir
.canonicalize()
.map_err(|e| CoreError::Internal(format!("failed to resolve pack skills dir: {e}")))?;
let now_utc = chrono::Utc::now();
let now = now_utc.format("%Y-%m-%d %H:%M:%S").to_string();
let now_ms: i64 = now_utc.timestamp_millis();
let mut tx = db.begin().await?;
let mut superseded_rule_ids: Vec<String> = Vec::new();
let mut written_dirs: Vec<std::path::PathBuf> = Vec::new();
for p in &prepared {
let version_tag = pack_version_tag(&manifest.id, &manifest.version);
let rule_tag = pack_rule_tag(&p.pack_rule_id);
let already_at_version: Option<String> = sqlx::query_scalar(
"SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%' \
AND tags LIKE '%' || ?3 || '%' LIMIT 1",
)
.bind(PACK_ORIGIN)
.bind(&rule_tag)
.bind(&version_tag)
.fetch_optional(&mut *tx)
.await?;
if already_at_version.is_some() {
continue;
}
let stale_ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%'",
)
.bind(PACK_ORIGIN)
.bind(&rule_tag)
.fetch_all(&mut *tx)
.await?;
for stale in &stale_ids {
sqlx::query("DELETE FROM rule_examples WHERE skill_id = ?1")
.bind(stale)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM skills WHERE id = ?1")
.bind(stale)
.execute(&mut *tx)
.await?;
let stale_dir = base_dir.join(stale);
let _ = std::fs::remove_dir_all(&stale_dir);
superseded_rule_ids.push(stale.clone());
}
let skill_dir = base_dir.join(&p.skill_id);
let skill_dir_for_check = canonical_base.join(&p.skill_id);
if !skill_dir_for_check.starts_with(&canonical_base) {
tx.rollback().await.ok();
cleanup_dirs(&written_dirs);
return Err(CoreError::Validation(
"install_pack: invalid slug after sanitization".into(),
));
}
std::fs::create_dir_all(&skill_dir)
.map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
let canonical_skill = skill_dir
.canonicalize()
.map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
if !canonical_skill.starts_with(&canonical_base) {
tx.rollback().await.ok();
cleanup_dirs(&written_dirs);
return Err(CoreError::Validation("install_pack: path escape".into()));
}
std::fs::write(skill_dir.join("SKILL.md"), &p.skill_md)
.map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
written_dirs.push(skill_dir.clone());
let engines_json = serde_json::to_string(&["claude"])?;
let tags_json = serde_json::to_string(&p.tags)?;
let file_patterns_json: Option<String> = if p.globs.is_empty() {
None
} else {
Some(serde_json::to_string(&p.globs)?)
};
sqlx::query(
"INSERT INTO skills
(id, name, source, directory, version, description, type, engines, tags,
trigger, check_prompt, file_patterns, source_repo, enabled_for_claude,
confidence_score, installed_at, updated_at, origin, content_hash, hash_created_at)
VALUES (?1, ?2, 'pack', ?3, ?4, ?5, 'review_standard', ?6, ?7,
NULL, NULL, ?8, ?9, 1, ?10, ?11, ?11, ?12, NULL, ?13)",
)
.bind(&p.skill_id)
.bind(&p.title)
.bind(&p.skill_id)
.bind(&manifest.version)
.bind(&p.body)
.bind(&engines_json)
.bind(&tags_json)
.bind(file_patterns_json.as_deref())
.bind(source_repo.as_str())
.bind(PACK_CONFIDENCE)
.bind(now.as_str())
.bind(PACK_ORIGIN)
.bind(now_ms)
.execute(&mut *tx)
.await?;
if let Some(example) = &p.example {
let ex_now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO rule_examples (id, skill_id, bad_code, good_code, description, source, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(&example.id)
.bind(&example.skill_id)
.bind(&example.bad_code)
.bind(&example.good_code)
.bind(example.description.as_deref())
.bind(&example.source)
.bind(&ex_now)
.execute(&mut *tx)
.await?;
}
}
if let Err(e) = tx.commit().await {
cleanup_dirs(&written_dirs);
return Err(e.into());
}
for p in &prepared {
if let Err(e) =
crate::skill_fs::sync_engine_link(PACK_SKILL_SOURCE, &p.skill_id, "claude", true)
{
eprintln!(
"warning: sync_engine_link failed for pack rule {}: {e}",
p.skill_id
);
}
}
Ok(InstallPackOutcome {
pack_id: manifest.id.clone(),
pack_version: manifest.version.clone(),
rules: installed,
superseded_rule_ids,
dry_run: false,
})
}
fn cleanup_dirs(dirs: &[std::path::PathBuf]) {
for dir in dirs {
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::manifest::{PackRuleExamples, PackTarget};
fn sample_manifest() -> PackManifest {
PackManifest {
schema_version: 1,
id: "difflore/go-http-safety".to_owned(),
name: "Go HTTP handler safety".to_owned(),
version: "1.2.0".to_owned(),
description: None,
target: Some(PackTarget {
languages: vec!["go".to_owned()],
frameworks: vec!["net/http".to_owned()],
file_globs: vec!["**/*.go".to_owned()],
}),
maintainer: None,
license: None,
provenance: None,
rules: vec![PackRule {
id: "go-http-safety/413-body-limit".to_owned(),
title: "Return 413 when a request body exceeds the size limit".to_owned(),
severity: Some("error".to_owned()),
file_globs: vec![],
tags: vec!["http".to_owned(), "security".to_owned()],
body: Some(
"Reject oversized request bodies with HTTP 413 instead of \
reading them unbounded into memory."
.to_owned(),
),
examples: Some(PackRuleExamples {
bad: Some("data, _ := io.ReadAll(r.Body)".to_owned()),
good: Some("r.Body = http.MaxBytesReader(w, r.Body, max)".to_owned()),
description: Some("reviewer flagged unbounded read".to_owned()),
}),
provenance: None,
}],
}
}
#[test]
fn local_skill_id_is_namespaced_and_not_a_uuid() {
let id = local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit");
assert!(id.starts_with("pack-difflore-go-http-safety-413-body-limit-"));
assert!(!id.contains('/'));
assert_eq!(
id,
local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit")
);
}
#[test]
fn mandatory_tags_present() {
let manifest = sample_manifest();
let tags = build_tags(&manifest.rules[0], &manifest);
assert!(tags.contains(&"pack".to_owned()));
assert!(tags.contains(&"pack:difflore/go-http-safety@1.2.0".to_owned()));
assert!(tags.contains(&"pack-rule:go-http-safety/413-body-limit".to_owned()));
assert!(tags.contains(&"go".to_owned()));
assert!(tags.contains(&"severity:error".to_owned()));
assert!(tags.contains(&"http".to_owned()));
}
#[test]
fn rule_globs_override_pack_default() {
let mut manifest = sample_manifest();
manifest.rules[0].file_globs = vec!["internal/http/**/*.go".to_owned()];
let globs = effective_globs(&manifest.rules[0], &manifest);
assert_eq!(globs, vec!["internal/http/**/*.go".to_owned()]);
}
#[test]
fn pack_default_globs_used_when_rule_has_none() {
let manifest = sample_manifest();
let globs = effective_globs(&manifest.rules[0], &manifest);
assert_eq!(globs, vec!["**/*.go".to_owned()]);
}
#[test]
fn body_renders_via_item_six_code_spec() {
let manifest = sample_manifest();
let rule = &manifest.rules[0];
let globs = effective_globs(rule, &manifest);
let skill_id = local_skill_id(&manifest.id, &rule.id);
let example = build_example(&skill_id, rule);
let body = render_body(&skill_id, rule, &manifest, &globs, example.as_ref());
assert!(body.starts_with(&format!("## Rule {skill_id} —")));
assert!(body.contains("Scope: **/*.go"));
assert!(body.contains("Confidence: 0.55"));
assert!(body.contains("Origin: pack"));
assert!(body.contains("### Contract"));
assert!(body.contains("### Cases"));
assert!(body.contains("### Validation / Error matrix"));
}
#[test]
fn example_requires_both_sides() {
let manifest = sample_manifest();
let rule = &manifest.rules[0];
let skill_id = local_skill_id(&manifest.id, &rule.id);
assert!(build_example(&skill_id, rule).is_some());
let mut one_sided = rule.clone();
one_sided.examples = Some(PackRuleExamples {
bad: Some("x".to_owned()),
good: None,
description: None,
});
assert!(build_example(&skill_id, &one_sided).is_none());
}
}