Skip to main content

tuff_console/
events.rs

1//! What a report says about a project, flattened, and the audit events that
2//! follow from comparing two of them (RFC-108 D7).
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde_json::Value;
7
8/// Names of the events, as stored and as the API returns them.
9pub 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    /// Every kind, in the order the UI lists them.
22    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/// One capability installed for one target: a row of the project's lockfile
37/// with its check status.
38#[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    /// Where it came from, as one line: `git:<url>@<ref>`, `local:<path>`,
45    /// `catalog:<id>@<version>`, or `pack:<name>@<version>`.
46    pub source: String,
47    /// The `tuff check` status of this row, `ok` when the file matches.
48    pub status: String,
49}
50
51/// A policy rule recorded as not enforced for a target.
52#[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/// A report reduced to what the inventory and the audit trail need.
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct Snapshot {
64    pub rows: Vec<InventoryRow>,
65    pub gaps: Vec<Gap>,
66}
67
68/// One change between two consecutive reports of a project.
69#[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
96/// A status that does not count as drift. A row the check did not cover is
97/// `unknown` and is treated as fine, since nothing says it changed.
98pub 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    /// Read the lockfile rows and check results of a report document. A
124    /// report that does not have the expected shape yields an empty
125    /// snapshot, so a malformed one never blocks ingest.
126    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
216/// The events that turn `previous` into `current`. With no previous report,
217/// everything in `current` is new, and the project itself is first seen.
218/// Events come in a fixed order, so equal inputs give equal output.
219pub 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        // One event per distinct change, so a capability updated for three
273        // harnesses is one entry that names them.
274        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, &current);
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), &current);
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), &current);
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), &current);
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}