Skip to main content

difflore_core/packs/
manifest.rs

1//! Serde types for the pack registry `index.json` catalog and per-pack
2//! `pack.json` manifest (roadmap §3, §6). The manifest pins only pack-level
3//! metadata + attribution and treats each rule's renderable content through
4//! item ⑥'s canonical body shape — it does not invent a second body format.
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// The registry catalog fetched on `packs list` / `packs install`.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct PackIndex {
13    pub schema_version: u32,
14    #[serde(default)]
15    pub generated_at: Option<String>,
16    #[serde(default)]
17    pub packs: Vec<PackIndexEntry>,
18}
19
20impl PackIndex {
21    /// Look up a catalog entry by registry-unique pack id.
22    #[must_use]
23    pub fn find(&self, pack_id: &str) -> Option<&PackIndexEntry> {
24        let needle = pack_id.trim();
25        self.packs.iter().find(|p| p.id == needle)
26    }
27}
28
29/// One pack's catalog row. Carries the per-version manifest path + `sha256`
30/// pin used to verify the fetched manifest (supply-chain guard).
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PackIndexEntry {
34    pub id: String,
35    pub name: String,
36    /// Default version when `packs install <id>` omits `@version`.
37    pub latest: String,
38    /// `version -> {manifest path, sha256, ruleCount}`.
39    #[serde(default)]
40    pub versions: std::collections::BTreeMap<String, PackIndexVersion>,
41    #[serde(default)]
42    pub target: Option<PackTarget>,
43    #[serde(default)]
44    pub maintainer: Option<PackMaintainer>,
45    #[serde(default)]
46    pub license: Option<String>,
47}
48
49impl PackIndexEntry {
50    /// Resolve a requested version (or `latest` when `None`) to its catalog
51    /// version row. Returns the resolved version string alongside it.
52    #[must_use]
53    pub fn resolve_version(&self, requested: Option<&str>) -> Option<(String, &PackIndexVersion)> {
54        let version = requested.map_or_else(|| self.latest.clone(), ToOwned::to_owned);
55        self.versions.get(&version).map(|v| (version, v))
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct PackIndexVersion {
62    /// Path to the `pack.json`, relative to the registry root.
63    pub manifest: String,
64    /// Hex `sha256` over the fetched manifest bytes — verified on install.
65    pub sha256: String,
66    #[serde(default)]
67    pub rule_count: Option<u32>,
68}
69
70/// The per-pack `pack.json` manifest.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct PackManifest {
74    pub schema_version: u32,
75    /// Registry-unique `<namespace>/<slug>`.
76    pub id: String,
77    pub name: String,
78    pub version: String,
79    #[serde(default)]
80    pub description: Option<String>,
81    #[serde(default)]
82    pub target: Option<PackTarget>,
83    #[serde(default)]
84    pub maintainer: Option<PackMaintainer>,
85    #[serde(default)]
86    pub license: Option<String>,
87    #[serde(default)]
88    pub provenance: Option<PackProvenance>,
89    #[serde(default)]
90    pub rules: Vec<PackRule>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PackTarget {
96    /// Drives the language tag + default file globs. The first entry becomes
97    /// the `RuleDocument.language` tag.
98    #[serde(default)]
99    pub languages: Vec<String>,
100    /// Informational; surfaced in `packs list` / `packs show`.
101    #[serde(default)]
102    pub frameworks: Vec<String>,
103    /// Pack-level default globs; a rule's own `fileGlobs` override these.
104    #[serde(default)]
105    pub file_globs: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct PackMaintainer {
111    pub name: String,
112    #[serde(default)]
113    pub url: Option<String>,
114    /// Set ONLY by the registry owner for first-party packs. A custom
115    /// `--registry` must render this as `verified (custom registry)` so the
116    /// trust badge is never misleading.
117    #[serde(default)]
118    pub verified: bool,
119}
120
121/// Pack-level provenance default. `kind` is the honesty contract (roadmap §3.3):
122/// `curated` | `mined` | `imported`. No `kind` may carry trust/acceptance
123/// numbers into the installing team's store.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct PackProvenance {
127    pub kind: String,
128    #[serde(default)]
129    pub summary: Option<String>,
130    #[serde(default)]
131    pub sources: Vec<PackProvenanceSource>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct PackProvenanceSource {
137    pub label: String,
138    #[serde(default)]
139    pub url: Option<String>,
140}
141
142/// One rule inside a manifest. `body` is the item-⑥-shaped renderable content;
143/// `examples` map to a `rule_examples` row when both sides are present.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct PackRule {
147    /// Pack-namespaced rule id (e.g. `go-http-safety/413-body-limit`).
148    pub id: String,
149    pub title: String,
150    /// `info` | `warning` | `error`. Becomes a `severity:<level>` tag so the
151    /// rule participates in the existing severity weighting honestly.
152    #[serde(default)]
153    pub severity: Option<String>,
154    /// Overrides `target.fileGlobs`. The strict-cascade gate that keeps a Go
155    /// pack rule off a `.py` edit.
156    #[serde(default)]
157    pub file_globs: Vec<String>,
158    #[serde(default)]
159    pub tags: Vec<String>,
160    /// The rule body prose. Authored in the same `Rule:` / first-sentence
161    /// directive shape the item-⑥ renderer parses, so the rendered code-spec is
162    /// identical to a mined rule's.
163    #[serde(default)]
164    pub body: Option<String>,
165    #[serde(default)]
166    pub examples: Option<PackRuleExamples>,
167    /// Per-rule provenance, overrides the pack-level default.
168    #[serde(default)]
169    pub provenance: Option<PackRuleProvenance>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PackRuleExamples {
175    #[serde(default)]
176    pub bad: Option<String>,
177    #[serde(default)]
178    pub good: Option<String>,
179    /// Optional reviewer-style note; flows to `rule_examples.description`.
180    #[serde(default)]
181    pub description: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct PackRuleProvenance {
187    pub kind: String,
188    #[serde(default)]
189    pub attribution: Option<String>,
190    #[serde(default)]
191    pub source_url: Option<String>,
192}
193
194/// Hex `sha256` over the raw manifest bytes, used as the supply-chain integrity
195/// check. The index pins this value; install recomputes it over the fetched
196/// bytes and refuses on mismatch.
197#[must_use]
198pub fn manifest_sha256(bytes: &[u8]) -> String {
199    let mut hasher = Sha256::new();
200    hasher.update(bytes);
201    let digest = hasher.finalize();
202    let mut hex = String::with_capacity(digest.len() * 2);
203    for byte in digest {
204        hex.push_str(&format!("{byte:02x}"));
205    }
206    hex
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    const SAMPLE_INDEX: &str = r#"{
214        "schemaVersion": 1,
215        "generatedAt": "2026-06-01T00:00:00Z",
216        "packs": [
217            {
218                "id": "difflore/go-http-safety",
219                "name": "Go HTTP handler safety",
220                "latest": "1.0.0",
221                "versions": {
222                    "1.0.0": {
223                        "manifest": "packs/difflore/go-http-safety/pack.json",
224                        "sha256": "deadbeef",
225                        "ruleCount": 6
226                    }
227                },
228                "target": { "languages": ["go"], "frameworks": ["net/http"] },
229                "maintainer": { "name": "DiffLore", "verified": true },
230                "license": "CC-BY-4.0"
231            }
232        ]
233    }"#;
234
235    #[test]
236    fn index_round_trips_and_finds_entry() {
237        let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
238        assert_eq!(index.schema_version, 1);
239        let entry = index.find("difflore/go-http-safety").expect("entry");
240        assert_eq!(entry.name, "Go HTTP handler safety");
241        assert_eq!(entry.latest, "1.0.0");
242        let (resolved, version) = entry.resolve_version(None).expect("latest");
243        assert_eq!(resolved, "1.0.0");
244        assert_eq!(version.sha256, "deadbeef");
245        assert_eq!(version.rule_count, Some(6));
246    }
247
248    #[test]
249    fn resolve_version_pins_explicit_request() {
250        let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
251        let entry = index.find("difflore/go-http-safety").expect("entry");
252        assert!(entry.resolve_version(Some("9.9.9")).is_none());
253        assert!(entry.resolve_version(Some("1.0.0")).is_some());
254    }
255
256    #[test]
257    fn manifest_sha256_is_deterministic_hex() {
258        let a = manifest_sha256(b"hello");
259        let b = manifest_sha256(b"hello");
260        assert_eq!(a, b);
261        assert_eq!(a.len(), 64);
262        assert_ne!(a, manifest_sha256(b"world"));
263    }
264
265    #[test]
266    fn manifest_parses_minimal_pack() {
267        let raw = r#"{
268            "schemaVersion": 1,
269            "id": "difflore/go-http-safety",
270            "name": "Go HTTP handler safety",
271            "version": "1.0.0",
272            "target": { "languages": ["go"], "fileGlobs": ["**/*.go"] },
273            "provenance": { "kind": "curated" },
274            "rules": [
275                {
276                    "id": "go-http-safety/413-body-limit",
277                    "title": "Return 413 when a request body exceeds the size limit",
278                    "severity": "error",
279                    "body": "Enforce a maximum request body size.",
280                    "examples": { "bad": "x", "good": "y" }
281                }
282            ]
283        }"#;
284        let manifest: PackManifest = serde_json::from_str(raw).expect("parse manifest");
285        assert_eq!(manifest.id, "difflore/go-http-safety");
286        assert_eq!(manifest.rules.len(), 1);
287        let rule = &manifest.rules[0];
288        assert_eq!(rule.severity.as_deref(), Some("error"));
289        assert_eq!(
290            manifest.target.as_ref().unwrap().file_globs,
291            vec!["**/*.go".to_owned()]
292        );
293    }
294}