1use std::collections::{BTreeMap, BTreeSet};
5
6use serde_json::Value;
7
8pub mod kind {
10 pub const PROJECT_FIRST_SEEN: &str = "project_first_seen";
11 pub const CAPABILITY_ADDED: &str = "capability_added";
12 pub const CAPABILITY_REMOVED: &str = "capability_removed";
13 pub const VERSION_CHANGED: &str = "version_changed";
14 pub const TARGET_ADDED: &str = "target_added";
15 pub const TARGET_REMOVED: &str = "target_removed";
16 pub const DRIFT_DETECTED: &str = "drift_detected";
17 pub const DRIFT_CLEARED: &str = "drift_cleared";
18 pub const POLICY_GAP_ADDED: &str = "policy_gap_added";
19 pub const POLICY_GAP_CLOSED: &str = "policy_gap_closed";
20
21 pub const ALL: &[&str] = &[
23 PROJECT_FIRST_SEEN,
24 CAPABILITY_ADDED,
25 CAPABILITY_REMOVED,
26 VERSION_CHANGED,
27 TARGET_ADDED,
28 TARGET_REMOVED,
29 DRIFT_DETECTED,
30 DRIFT_CLEARED,
31 POLICY_GAP_ADDED,
32 POLICY_GAP_CLOSED,
33 ];
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct InventoryRow {
40 pub capability_type: String,
41 pub capability_id: String,
42 pub target: String,
43 pub version: String,
44 pub source: String,
47 pub status: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
53pub struct Gap {
54 pub policy: String,
55 pub target: String,
56 pub rule: usize,
57 pub description: String,
58 pub reason: String,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct Snapshot {
64 pub rows: Vec<InventoryRow>,
65 pub gaps: Vec<Gap>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Event {
71 pub kind: &'static str,
72 pub capability_type: Option<String>,
73 pub capability_id: Option<String>,
74 pub target: Option<String>,
75 pub detail: Option<String>,
76}
77
78impl Event {
79 fn capability(
80 kind: &'static str,
81 capability_type: &str,
82 capability_id: &str,
83 target: Option<String>,
84 detail: Option<String>,
85 ) -> Self {
86 Self {
87 kind,
88 capability_type: Some(capability_type.to_string()),
89 capability_id: Some(capability_id.to_string()),
90 target,
91 detail,
92 }
93 }
94}
95
96pub fn is_ok(status: &str) -> bool {
99 status == "ok" || status == "unknown"
100}
101
102fn text(value: &Value, key: &str) -> String {
103 value
104 .get(key)
105 .and_then(Value::as_str)
106 .unwrap_or_default()
107 .to_string()
108}
109
110fn source_label(source: &Value) -> String {
111 let field = |key: &str| text(source, key);
112 match source.get("kind").and_then(Value::as_str) {
113 Some("git") => format!("git:{}@{}", field("url"), field("ref")),
114 Some("local") => format!("local:{}", field("path")),
115 Some("catalog") => format!("catalog:{}@{}", field("id"), field("version")),
116 Some("pack") => format!("pack:{}@{}", field("name"), field("version")),
117 Some(other) => other.to_string(),
118 None => String::new(),
119 }
120}
121
122impl Snapshot {
123 pub fn from_report(report: &Value) -> Self {
127 let statuses: BTreeMap<(String, String, String), String> = report
128 .pointer("/check/results")
129 .and_then(Value::as_array)
130 .into_iter()
131 .flatten()
132 .map(|result| {
133 (
134 (
135 text(result, "type"),
136 text(result, "id"),
137 text(result, "target"),
138 ),
139 text(result, "status"),
140 )
141 })
142 .collect();
143
144 let mut rows = Vec::new();
145 let mut gaps = Vec::new();
146 for entry in report
147 .pointer("/lockfile/capabilities")
148 .and_then(Value::as_array)
149 .into_iter()
150 .flatten()
151 {
152 let capability_type = text(entry, "type");
153 let capability_id = text(entry, "name");
154 let target = text(entry, "target");
155 if capability_id.is_empty() {
156 continue;
157 }
158 let status = statuses
159 .get(&(
160 capability_type.clone(),
161 capability_id.clone(),
162 target.clone(),
163 ))
164 .cloned()
165 .unwrap_or_else(|| "unknown".to_string());
166 for rule in entry
167 .get("unenforced_rules")
168 .and_then(Value::as_array)
169 .into_iter()
170 .flatten()
171 {
172 gaps.push(Gap {
173 policy: capability_id.clone(),
174 target: target.clone(),
175 rule: rule.get("rule").and_then(Value::as_u64).unwrap_or_default() as usize,
176 description: text(rule, "description"),
177 reason: text(rule, "reason"),
178 });
179 }
180 rows.push(InventoryRow {
181 capability_type,
182 capability_id,
183 target,
184 version: text(entry, "version"),
185 source: entry.get("source").map(source_label).unwrap_or_default(),
186 status,
187 });
188 }
189 rows.sort_by(|a, b| {
190 (&a.capability_type, &a.capability_id, &a.target).cmp(&(
191 &b.capability_type,
192 &b.capability_id,
193 &b.target,
194 ))
195 });
196 gaps.sort();
197 Self { rows, gaps }
198 }
199
200 fn by_capability(&self) -> BTreeMap<(&str, &str), Vec<&InventoryRow>> {
201 let mut map: BTreeMap<(&str, &str), Vec<&InventoryRow>> = BTreeMap::new();
202 for row in &self.rows {
203 map.entry((row.capability_type.as_str(), row.capability_id.as_str()))
204 .or_default()
205 .push(row);
206 }
207 map
208 }
209}
210
211fn join_targets<'a>(targets: impl IntoIterator<Item = &'a str>) -> Option<String> {
212 let joined = targets.into_iter().collect::<Vec<_>>().join(", ");
213 (!joined.is_empty()).then_some(joined)
214}
215
216pub fn diff(previous: Option<&Snapshot>, current: &Snapshot) -> Vec<Event> {
220 let mut events = Vec::new();
221 if previous.is_none() {
222 events.push(Event {
223 kind: kind::PROJECT_FIRST_SEEN,
224 capability_type: None,
225 capability_id: None,
226 target: None,
227 detail: None,
228 });
229 }
230 let empty = Snapshot::default();
231 let previous = previous.unwrap_or(&empty);
232 let before = previous.by_capability();
233 let after = current.by_capability();
234
235 for (&(capability_type, capability_id), rows) in &after {
236 let Some(old_rows) = before.get(&(capability_type, capability_id)) else {
237 events.push(Event::capability(
238 kind::CAPABILITY_ADDED,
239 capability_type,
240 capability_id,
241 join_targets(rows.iter().map(|row| row.target.as_str())),
242 rows.first().map(|row| row.version.clone()),
243 ));
244 continue;
245 };
246 let old: BTreeMap<&str, &InventoryRow> = old_rows
247 .iter()
248 .map(|row| (row.target.as_str(), *row))
249 .collect();
250 let new: BTreeMap<&str, &InventoryRow> =
251 rows.iter().map(|row| (row.target.as_str(), *row)).collect();
252
253 for target in new.keys().filter(|target| !old.contains_key(*target)) {
254 events.push(Event::capability(
255 kind::TARGET_ADDED,
256 capability_type,
257 capability_id,
258 Some((*target).to_string()),
259 None,
260 ));
261 }
262 for target in old.keys().filter(|target| !new.contains_key(*target)) {
263 events.push(Event::capability(
264 kind::TARGET_REMOVED,
265 capability_type,
266 capability_id,
267 Some((*target).to_string()),
268 None,
269 ));
270 }
271
272 let mut changes: BTreeMap<String, Vec<&str>> = BTreeMap::new();
275 for (target, row) in &new {
276 let Some(old_row) = old.get(target) else {
277 continue;
278 };
279 if old_row.version != row.version {
280 changes
281 .entry(format!("{} to {}", old_row.version, row.version))
282 .or_default()
283 .push(target);
284 } else if old_row.source != row.source {
285 changes
286 .entry(format!("source {} to {}", old_row.source, row.source))
287 .or_default()
288 .push(target);
289 }
290 }
291 for (detail, targets) in changes {
292 events.push(Event::capability(
293 kind::VERSION_CHANGED,
294 capability_type,
295 capability_id,
296 join_targets(targets),
297 Some(detail),
298 ));
299 }
300
301 for (target, row) in &new {
302 let Some(old_row) = old.get(target) else {
303 continue;
304 };
305 let (was_ok, is_now_ok) = (is_ok(&old_row.status), is_ok(&row.status));
306 if was_ok && !is_now_ok {
307 events.push(Event::capability(
308 kind::DRIFT_DETECTED,
309 capability_type,
310 capability_id,
311 Some((*target).to_string()),
312 Some(row.status.clone()),
313 ));
314 } else if !was_ok && is_now_ok {
315 events.push(Event::capability(
316 kind::DRIFT_CLEARED,
317 capability_type,
318 capability_id,
319 Some((*target).to_string()),
320 None,
321 ));
322 }
323 }
324 }
325
326 for (&(capability_type, capability_id), old_rows) in &before {
327 if !after.contains_key(&(capability_type, capability_id)) {
328 events.push(Event::capability(
329 kind::CAPABILITY_REMOVED,
330 capability_type,
331 capability_id,
332 join_targets(old_rows.iter().map(|row| row.target.as_str())),
333 old_rows.first().map(|row| row.version.clone()),
334 ));
335 }
336 }
337
338 let old_gaps: BTreeSet<&Gap> = previous.gaps.iter().collect();
339 let new_gaps: BTreeSet<&Gap> = current.gaps.iter().collect();
340 for (set, other, kind) in [
341 (&new_gaps, &old_gaps, kind::POLICY_GAP_ADDED),
342 (&old_gaps, &new_gaps, kind::POLICY_GAP_CLOSED),
343 ] {
344 for gap in set.difference(other) {
345 events.push(Event::capability(
346 kind,
347 "policy",
348 &gap.policy,
349 Some(gap.target.clone()),
350 Some(format!("rule {}: {}", gap.rule, gap.description)),
351 ));
352 }
353 }
354 events
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use serde_json::json;
361
362 fn report(rows: Value, statuses: Value) -> Value {
363 json!({
364 "lockfile": { "version": 3, "capabilities": rows },
365 "check": { "valid": true, "results": statuses },
366 })
367 }
368
369 fn skill(name: &str, target: &str, version: &str) -> Value {
370 json!({
371 "name": name, "type": "skill", "target": target, "version": version,
372 "source": { "kind": "git", "url": "https://example.test/x", "ref": "abc" },
373 })
374 }
375
376 fn status(name: &str, target: &str, status: &str) -> Value {
377 json!({ "id": name, "type": "skill", "target": target, "status": status })
378 }
379
380 fn kinds(events: &[Event]) -> Vec<&'static str> {
381 events.iter().map(|event| event.kind).collect()
382 }
383
384 #[test]
385 fn a_report_is_flattened_to_one_row_per_capability_and_target() {
386 let snapshot = Snapshot::from_report(&report(
387 json!([
388 skill("lint", "claude", "1.0.0"),
389 skill("lint", "codex", "1.0.0")
390 ]),
391 json!([status("lint", "claude", "modified")]),
392 ));
393 assert_eq!(snapshot.rows.len(), 2);
394 assert_eq!(snapshot.rows[0].target, "claude");
395 assert_eq!(snapshot.rows[0].status, "modified");
396 assert_eq!(snapshot.rows[1].status, "unknown");
397 assert_eq!(snapshot.rows[0].source, "git:https://example.test/x@abc");
398 }
399
400 #[test]
401 fn a_malformed_report_is_an_empty_snapshot() {
402 assert_eq!(
403 Snapshot::from_report(&json!({ "lockfile": 3 })),
404 Snapshot::default()
405 );
406 }
407
408 #[test]
409 fn the_first_report_names_the_project_and_what_it_carries() {
410 let current = Snapshot::from_report(&report(
411 json!([skill("lint", "claude", "1.0.0")]),
412 json!([]),
413 ));
414 let events = diff(None, ¤t);
415 assert_eq!(
416 kinds(&events),
417 [kind::PROJECT_FIRST_SEEN, kind::CAPABILITY_ADDED]
418 );
419 assert_eq!(events[1].capability_id.as_deref(), Some("lint"));
420 }
421
422 #[test]
423 fn equal_snapshots_have_no_events() {
424 let snapshot = Snapshot::from_report(&report(
425 json!([skill("lint", "claude", "1.0.0")]),
426 json!([status("lint", "claude", "ok")]),
427 ));
428 assert!(diff(Some(&snapshot), &snapshot).is_empty());
429 }
430
431 #[test]
432 fn changes_between_two_reports_are_named() {
433 let previous = Snapshot::from_report(&report(
434 json!([
435 skill("lint", "claude", "1.0.0"),
436 skill("lint", "codex", "1.0.0"),
437 skill("gone", "claude", "1.0.0"),
438 skill("drifty", "claude", "1.0.0"),
439 skill("healed", "claude", "1.0.0"),
440 ]),
441 json!([
442 status("drifty", "claude", "ok"),
443 status("healed", "claude", "modified"),
444 ]),
445 ));
446 let mut retargeted = skill("lint", "cursor", "1.1.0");
447 retargeted["source"]["ref"] = json!("def");
448 let current = Snapshot::from_report(&report(
449 json!([
450 skill("lint", "claude", "1.1.0"),
451 retargeted,
452 skill("new", "claude", "0.1.0"),
453 skill("drifty", "claude", "1.0.0"),
454 skill("healed", "claude", "1.0.0"),
455 ]),
456 json!([
457 status("drifty", "claude", "modified"),
458 status("healed", "claude", "ok"),
459 ]),
460 ));
461 let events = diff(Some(&previous), ¤t);
462 let got: Vec<(&str, &str, Option<&str>)> = events
463 .iter()
464 .map(|event| {
465 (
466 event.kind,
467 event.capability_id.as_deref().unwrap(),
468 event.target.as_deref(),
469 )
470 })
471 .collect();
472 assert_eq!(
473 got,
474 [
475 (kind::DRIFT_DETECTED, "drifty", Some("claude")),
476 (kind::DRIFT_CLEARED, "healed", Some("claude")),
477 (kind::TARGET_ADDED, "lint", Some("cursor")),
478 (kind::TARGET_REMOVED, "lint", Some("codex")),
479 (kind::VERSION_CHANGED, "lint", Some("claude")),
480 (kind::CAPABILITY_ADDED, "new", Some("claude")),
481 (kind::CAPABILITY_REMOVED, "gone", Some("claude")),
482 ]
483 );
484 let version = events
485 .iter()
486 .find(|event| event.kind == kind::VERSION_CHANGED)
487 .unwrap();
488 assert_eq!(version.detail.as_deref(), Some("1.0.0 to 1.1.0"));
489 }
490
491 #[test]
492 fn a_new_ref_with_the_same_version_is_a_version_change() {
493 let previous =
494 Snapshot::from_report(&report(json!([skill("lint", "claude", "abc")]), json!([])));
495 let mut moved = skill("lint", "claude", "abc");
496 moved["source"]["ref"] = json!("def");
497 let current = Snapshot::from_report(&report(json!([moved]), json!([])));
498 let events = diff(Some(&previous), ¤t);
499 assert_eq!(kinds(&events), [kind::VERSION_CHANGED]);
500 assert!(
501 events[0]
502 .detail
503 .as_deref()
504 .unwrap()
505 .starts_with("source git:")
506 );
507 }
508
509 #[test]
510 fn one_update_for_several_targets_is_one_event() {
511 let previous = Snapshot::from_report(&report(
512 json!([skill("lint", "claude", "1"), skill("lint", "codex", "1")]),
513 json!([]),
514 ));
515 let current = Snapshot::from_report(&report(
516 json!([skill("lint", "claude", "2"), skill("lint", "codex", "2")]),
517 json!([]),
518 ));
519 let events = diff(Some(&previous), ¤t);
520 assert_eq!(events.len(), 1);
521 assert_eq!(events[0].target.as_deref(), Some("claude, codex"));
522 }
523
524 #[test]
525 fn policy_gaps_open_and_close() {
526 let with_gap = |rule: usize| {
527 let mut policy = json!({
528 "name": "guard", "type": "policy", "target": "codex", "version": "1",
529 "source": { "kind": "local", "path": "p" },
530 });
531 policy["unenforced_rules"] = json!([{ "rule": rule, "description": "deny read \".env\"", "reason": "no native rule" }]);
532 Snapshot::from_report(&report(json!([policy]), json!([])))
533 };
534 let added = diff(Some(&Snapshot::default()), &with_gap(2));
535 assert!(kinds(&added).contains(&kind::POLICY_GAP_ADDED));
536 let closed = diff(Some(&with_gap(2)), &Snapshot::default());
537 assert!(kinds(&closed).contains(&kind::POLICY_GAP_CLOSED));
538 assert!(diff(Some(&with_gap(2)), &with_gap(2)).is_empty());
539 }
540}