use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackIndex {
pub schema_version: u32,
#[serde(default)]
pub generated_at: Option<String>,
#[serde(default)]
pub packs: Vec<PackIndexEntry>,
}
impl PackIndex {
#[must_use]
pub fn find(&self, pack_id: &str) -> Option<&PackIndexEntry> {
let needle = pack_id.trim();
self.packs.iter().find(|p| p.id == needle)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackIndexEntry {
pub id: String,
pub name: String,
pub latest: String,
#[serde(default)]
pub versions: std::collections::BTreeMap<String, PackIndexVersion>,
#[serde(default)]
pub target: Option<PackTarget>,
#[serde(default)]
pub maintainer: Option<PackMaintainer>,
#[serde(default)]
pub license: Option<String>,
}
impl PackIndexEntry {
#[must_use]
pub fn resolve_version(&self, requested: Option<&str>) -> Option<(String, &PackIndexVersion)> {
let version = requested.map_or_else(|| self.latest.clone(), ToOwned::to_owned);
self.versions.get(&version).map(|v| (version, v))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackIndexVersion {
pub manifest: String,
pub sha256: String,
#[serde(default)]
pub rule_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackManifest {
pub schema_version: u32,
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub target: Option<PackTarget>,
#[serde(default)]
pub maintainer: Option<PackMaintainer>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub provenance: Option<PackProvenance>,
#[serde(default)]
pub rules: Vec<PackRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackTarget {
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub frameworks: Vec<String>,
#[serde(default)]
pub file_globs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackMaintainer {
pub name: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackProvenance {
pub kind: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub sources: Vec<PackProvenanceSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackProvenanceSource {
pub label: String,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackRule {
pub id: String,
pub title: String,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub file_globs: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub examples: Option<PackRuleExamples>,
#[serde(default)]
pub provenance: Option<PackRuleProvenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackRuleExamples {
#[serde(default)]
pub bad: Option<String>,
#[serde(default)]
pub good: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackRuleProvenance {
pub kind: String,
#[serde(default)]
pub attribution: Option<String>,
#[serde(default)]
pub source_url: Option<String>,
}
#[must_use]
pub fn manifest_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_INDEX: &str = r#"{
"schemaVersion": 1,
"generatedAt": "2026-06-01T00:00:00Z",
"packs": [
{
"id": "difflore/go-http-safety",
"name": "Go HTTP handler safety",
"latest": "1.0.0",
"versions": {
"1.0.0": {
"manifest": "packs/difflore/go-http-safety/pack.json",
"sha256": "deadbeef",
"ruleCount": 6
}
},
"target": { "languages": ["go"], "frameworks": ["net/http"] },
"maintainer": { "name": "DiffLore", "verified": true },
"license": "CC-BY-4.0"
}
]
}"#;
#[test]
fn index_round_trips_and_finds_entry() {
let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
assert_eq!(index.schema_version, 1);
let entry = index.find("difflore/go-http-safety").expect("entry");
assert_eq!(entry.name, "Go HTTP handler safety");
assert_eq!(entry.latest, "1.0.0");
let (resolved, version) = entry.resolve_version(None).expect("latest");
assert_eq!(resolved, "1.0.0");
assert_eq!(version.sha256, "deadbeef");
assert_eq!(version.rule_count, Some(6));
}
#[test]
fn resolve_version_pins_explicit_request() {
let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
let entry = index.find("difflore/go-http-safety").expect("entry");
assert!(entry.resolve_version(Some("9.9.9")).is_none());
assert!(entry.resolve_version(Some("1.0.0")).is_some());
}
#[test]
fn manifest_sha256_is_deterministic_hex() {
let a = manifest_sha256(b"hello");
let b = manifest_sha256(b"hello");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert_ne!(a, manifest_sha256(b"world"));
}
#[test]
fn manifest_parses_minimal_pack() {
let raw = r#"{
"schemaVersion": 1,
"id": "difflore/go-http-safety",
"name": "Go HTTP handler safety",
"version": "1.0.0",
"target": { "languages": ["go"], "fileGlobs": ["**/*.go"] },
"provenance": { "kind": "curated" },
"rules": [
{
"id": "go-http-safety/413-body-limit",
"title": "Return 413 when a request body exceeds the size limit",
"severity": "error",
"body": "Enforce a maximum request body size.",
"examples": { "bad": "x", "good": "y" }
}
]
}"#;
let manifest: PackManifest = serde_json::from_str(raw).expect("parse manifest");
assert_eq!(manifest.id, "difflore/go-http-safety");
assert_eq!(manifest.rules.len(), 1);
let rule = &manifest.rules[0];
assert_eq!(rule.severity.as_deref(), Some("error"));
assert_eq!(
manifest.target.as_ref().unwrap().file_globs,
vec!["**/*.go".to_owned()]
);
}
}