Skip to main content

tuff_console/
views.rs

1//! The read side of the API (RFC-108 D9): what the latest report of every
2//! project says, grouped the way each UI view needs it.
3//!
4//! Everything here is computed from the latest stored report of each
5//! project. A console holds one row per project for this, so the work grows
6//! with the number of projects and not with the number of reports.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::Serialize;
11use serde_json::{Value, json};
12
13use crate::events::{Gap, InventoryRow, Snapshot, is_ok};
14use crate::store::ProjectRow;
15
16/// Harnesses in the order the matrix lists them. Others follow
17/// alphabetically.
18const HARNESS_ORDER: &[&str] = &["claude", "codex", "cursor", "opencode", "open-agents"];
19
20/// The newest version `tuff outdated` found for one capability and target.
21#[derive(Debug, Clone)]
22struct Newer {
23    latest: Option<String>,
24}
25
26/// One project as the latest report describes it.
27#[derive(Debug, Clone)]
28pub struct ProjectModel {
29    row: ProjectRow,
30    commit: Option<String>,
31    branch: Option<String>,
32    dirty: bool,
33    snapshot: Snapshot,
34    outdated: BTreeMap<(String, String, String), Newer>,
35}
36
37/// One capability installed for one target, with what is known about its
38/// state.
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41struct CapabilityRow {
42    #[serde(rename = "type")]
43    capability_type: String,
44    id: String,
45    target: String,
46    version: String,
47    source: String,
48    status: String,
49    outdated: bool,
50    latest: Option<String>,
51}
52
53impl ProjectModel {
54    pub fn new(row: ProjectRow, report: &Value) -> Self {
55        let outdated = report
56            .get("outdated")
57            .and_then(Value::as_array)
58            .into_iter()
59            .flatten()
60            .filter(|entry| entry.get("status").and_then(Value::as_str) == Some("outdated"))
61            .map(|entry| {
62                let text = |key: &str| {
63                    entry
64                        .get(key)
65                        .and_then(Value::as_str)
66                        .unwrap_or_default()
67                        .to_string()
68                };
69                (
70                    (text("type"), text("id"), text("target")),
71                    Newer {
72                        latest: entry
73                            .get("latest")
74                            .and_then(Value::as_str)
75                            .map(str::to_string),
76                    },
77                )
78            })
79            .collect();
80        let text = |pointer: &str| {
81            report
82                .pointer(pointer)
83                .and_then(Value::as_str)
84                .map(str::to_string)
85        };
86        Self {
87            row,
88            commit: text("/project/commit"),
89            branch: text("/project/branch"),
90            dirty: report
91                .pointer("/project/dirty")
92                .and_then(Value::as_bool)
93                .unwrap_or(false),
94            snapshot: Snapshot::from_report(report),
95            outdated,
96        }
97    }
98
99    fn capabilities(&self) -> Vec<CapabilityRow> {
100        self.snapshot
101            .rows
102            .iter()
103            .map(|row| self.capability_row(row))
104            .collect()
105    }
106
107    fn capability_row(&self, row: &InventoryRow) -> CapabilityRow {
108        let newer = self.outdated.get(&(
109            row.capability_type.clone(),
110            row.capability_id.clone(),
111            row.target.clone(),
112        ));
113        CapabilityRow {
114            capability_type: row.capability_type.clone(),
115            id: row.capability_id.clone(),
116            target: row.target.clone(),
117            version: row.version.clone(),
118            source: row.source.clone(),
119            status: row.status.clone(),
120            outdated: newer.is_some(),
121            latest: newer.and_then(|newer| newer.latest.clone()),
122        }
123    }
124
125    fn harnesses(&self) -> Vec<String> {
126        let set: BTreeSet<&str> = self
127            .snapshot
128            .rows
129            .iter()
130            .map(|row| row.target.as_str())
131            .collect();
132        order_harnesses(set)
133    }
134
135    fn distinct_capabilities(&self) -> usize {
136        self.snapshot
137            .rows
138            .iter()
139            .map(|row| (&row.capability_type, &row.capability_id))
140            .collect::<BTreeSet<_>>()
141            .len()
142    }
143
144    /// Capabilities with a target whose files no longer match.
145    fn drifted(&self) -> usize {
146        self.snapshot
147            .rows
148            .iter()
149            .filter(|row| !is_ok(&row.status))
150            .map(|row| (&row.capability_type, &row.capability_id))
151            .collect::<BTreeSet<_>>()
152            .len()
153    }
154
155    fn outdated_count(&self) -> usize {
156        self.outdated
157            .keys()
158            .map(|(kind, id, _)| (kind, id))
159            .collect::<BTreeSet<_>>()
160            .len()
161    }
162
163    /// `drift`, `gap`, `outdated`, or `ok`, worst first.
164    fn status(&self) -> &'static str {
165        if self.drifted() > 0 {
166            "drift"
167        } else if !self.snapshot.gaps.is_empty() {
168            "gap"
169        } else if !self.outdated.is_empty() {
170            "outdated"
171        } else {
172            "ok"
173        }
174    }
175
176    fn identity(&self) -> Value {
177        json!({
178            "id": self.row.id,
179            "name": self.row.name,
180            "repository": self.row.repository,
181            "path": self.row.path,
182        })
183    }
184
185    fn list_item(&self) -> Value {
186        json!({
187            "id": self.row.id,
188            "repository": self.row.repository,
189            "path": self.row.path,
190            "name": self.row.name,
191            "firstReportAt": self.row.first_report_at,
192            "lastReportAt": self.row.last_report_at,
193            "reportCount": self.row.report_count,
194            "commit": self.commit,
195            "branch": self.branch,
196            "dirty": self.dirty,
197            "harnesses": self.harnesses(),
198            "capabilityCount": self.distinct_capabilities(),
199            "driftCount": self.drifted(),
200            "outdatedCount": self.outdated_count(),
201            "policyGapCount": self.snapshot.gaps.len(),
202            "status": self.status(),
203        })
204    }
205}
206
207fn order_harnesses<'a>(set: impl IntoIterator<Item = &'a str>) -> Vec<String> {
208    let mut names: Vec<&str> = set.into_iter().collect();
209    names.sort_by_key(|name| {
210        (
211            HARNESS_ORDER
212                .iter()
213                .position(|known| known == name)
214                .unwrap_or(HARNESS_ORDER.len()),
215            *name,
216        )
217    });
218    names.dedup();
219    names.into_iter().map(str::to_string).collect()
220}
221
222/// `GET /projects`: each project with its summary, and the totals the
223/// Dashboard shows.
224pub fn projects(models: &[ProjectModel]) -> Value {
225    let capabilities: BTreeSet<(&str, &str)> = models
226        .iter()
227        .flat_map(|model| &model.snapshot.rows)
228        .map(|row| (row.capability_type.as_str(), row.capability_id.as_str()))
229        .collect();
230    let harnesses: BTreeSet<&str> = models
231        .iter()
232        .flat_map(|model| &model.snapshot.rows)
233        .map(|row| row.target.as_str())
234        .collect();
235    let repositories: BTreeSet<&str> = models
236        .iter()
237        .map(|model| model.row.repository.as_str())
238        .collect();
239    let last_report_at = models.iter().map(|model| &model.row.last_report_at).max();
240    json!({
241        "projects": models.iter().map(ProjectModel::list_item).collect::<Vec<_>>(),
242        "summary": {
243            "projects": models.len(),
244            "repositories": repositories.len(),
245            "capabilities": capabilities.len(),
246            "harnesses": harnesses.len(),
247            "driftCount": models.iter().map(ProjectModel::drifted).sum::<usize>(),
248            "outdatedCount": models.iter().map(ProjectModel::outdated_count).sum::<usize>(),
249            "policyGapCount": models.iter().map(|model| model.snapshot.gaps.len()).sum::<usize>(),
250            "lastReportAt": last_report_at,
251        },
252    })
253}
254
255fn gap_json(model: &ProjectModel, gap: &Gap) -> Value {
256    json!({
257        "projectId": model.row.id,
258        "name": model.row.name,
259        "repository": model.row.repository,
260        "path": model.row.path,
261        "policy": gap.policy,
262        "target": gap.target,
263        "rule": gap.rule,
264        "description": gap.description,
265        "reason": gap.reason,
266    })
267}
268
269/// `GET /projects/{id}`: one project, its capabilities per target, its
270/// recorded policy gaps, and its latest report as stored.
271pub fn project(model: &ProjectModel, latest_report: Value) -> Value {
272    json!({
273        "project": model.list_item(),
274        "capabilities": model.capabilities(),
275        "policyGaps": model
276            .snapshot
277            .gaps
278            .iter()
279            .map(|gap| gap_json(model, gap))
280            .collect::<Vec<_>>(),
281        "latestReport": latest_report,
282    })
283}
284
285struct VersionCount {
286    version: String,
287    projects: BTreeSet<i64>,
288}
289
290fn version_counts<'a>(rows: impl Iterator<Item = (i64, &'a str)>) -> (Vec<Value>, bool) {
291    let mut by_version: BTreeMap<&str, BTreeSet<i64>> = BTreeMap::new();
292    for (project, version) in rows {
293        by_version.entry(version).or_default().insert(project);
294    }
295    let mut counts: Vec<VersionCount> = by_version
296        .into_iter()
297        .map(|(version, projects)| VersionCount {
298            version: version.to_string(),
299            projects,
300        })
301        .collect();
302    counts.sort_by(|a, b| {
303        b.projects
304            .len()
305            .cmp(&a.projects.len())
306            .then_with(|| a.version.cmp(&b.version))
307    });
308    let mixed = counts.len() > 1;
309    (
310        counts
311            .into_iter()
312            .map(|count| json!({ "version": count.version, "projects": count.projects.len() }))
313            .collect(),
314        mixed,
315    )
316}
317
318/// `GET /capabilities`: every capability across projects, with the
319/// versions in use. `type_filter` keeps one capability type.
320pub fn capabilities(models: &[ProjectModel], type_filter: Option<&str>) -> Value {
321    type Key<'a> = (&'a str, &'a str);
322    let mut grouped: BTreeMap<Key<'_>, Vec<(&ProjectModel, &InventoryRow)>> = BTreeMap::new();
323    for model in models {
324        for row in &model.snapshot.rows {
325            if type_filter.is_some_and(|wanted| wanted != row.capability_type) {
326                continue;
327            }
328            grouped
329                .entry((row.capability_type.as_str(), row.capability_id.as_str()))
330                .or_default()
331                .push((model, row));
332        }
333    }
334    let mut items: Vec<(usize, Value)> = grouped
335        .into_iter()
336        .map(|((kind, id), uses)| {
337            let projects: BTreeMap<i64, &ProjectModel> = uses
338                .iter()
339                .map(|(model, _)| (model.row.id, *model))
340                .collect();
341            let (versions, mixed) = version_counts(
342                uses.iter()
343                    .map(|(model, row)| (model.row.id, row.version.as_str())),
344            );
345            let targets: BTreeSet<&str> = uses.iter().map(|(_, row)| row.target.as_str()).collect();
346            (
347                projects.len(),
348                json!({
349                    "type": kind,
350                    "id": id,
351                    "versions": versions,
352                    "mixed": mixed,
353                    "projectCount": projects.len(),
354                    "targets": order_harnesses(targets),
355                    "projects": projects.values().map(|model| model.identity()).collect::<Vec<_>>(),
356                }),
357            )
358        })
359        .collect();
360    items.sort_by(|a, b| {
361        b.0.cmp(&a.0).then_with(|| {
362            let key = |value: &Value| {
363                (
364                    value["type"].as_str().unwrap_or_default().to_string(),
365                    value["id"].as_str().unwrap_or_default().to_string(),
366                )
367            };
368            key(&a.1).cmp(&key(&b.1))
369        })
370    });
371    let types: BTreeSet<&str> = models
372        .iter()
373        .flat_map(|model| &model.snapshot.rows)
374        .map(|row| row.capability_type.as_str())
375        .collect();
376    json!({
377        "capabilities": items.into_iter().map(|(_, value)| value).collect::<Vec<_>>(),
378        "types": types,
379    })
380}
381
382/// `GET /capabilities/{type}/{id}`: where one capability is used.
383pub fn capability(models: &[ProjectModel], capability_type: &str, id: &str) -> Option<Value> {
384    let mut usage = Vec::new();
385    let mut versions = Vec::new();
386    for model in models {
387        for row in &model.snapshot.rows {
388            if row.capability_type == capability_type && row.capability_id == id {
389                versions.push((model.row.id, row.version.as_str()));
390                let detail = model.capability_row(row);
391                usage.push(json!({
392                    "projectId": model.row.id,
393                    "name": model.row.name,
394                    "repository": model.row.repository,
395                    "path": model.row.path,
396                    "target": detail.target,
397                    "version": detail.version,
398                    "source": detail.source,
399                    "status": detail.status,
400                    "outdated": detail.outdated,
401                    "latest": detail.latest,
402                }));
403            }
404        }
405    }
406    if usage.is_empty() {
407        return None;
408    }
409    let (version_list, mixed) = version_counts(versions.into_iter());
410    let project_count = usage
411        .iter()
412        .filter_map(|entry| entry["projectId"].as_i64())
413        .collect::<BTreeSet<_>>()
414        .len();
415    Some(json!({
416        "type": capability_type,
417        "id": id,
418        "versions": version_list,
419        "mixed": mixed,
420        "projectCount": project_count,
421        "usage": usage,
422    }))
423}
424
425/// `GET /harnesses`: the project by harness matrix. A cell is the number of
426/// capabilities the project has installed for that harness.
427pub fn harnesses(models: &[ProjectModel]) -> Value {
428    let all: BTreeSet<&str> = models
429        .iter()
430        .flat_map(|model| &model.snapshot.rows)
431        .map(|row| row.target.as_str())
432        .collect();
433    let names = order_harnesses(all);
434    let rows: Vec<Value> = models
435        .iter()
436        .map(|model| {
437            let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
438            for row in &model.snapshot.rows {
439                *counts.entry(row.target.as_str()).or_default() += 1;
440            }
441            let mut identity = model.identity();
442            identity["counts"] = json!(counts);
443            identity
444        })
445        .collect();
446    let totals: BTreeMap<&str, usize> = names
447        .iter()
448        .map(|name| {
449            (
450                name.as_str(),
451                models
452                    .iter()
453                    .filter(|model| model.snapshot.rows.iter().any(|row| row.target == *name))
454                    .count(),
455            )
456        })
457        .collect();
458    json!({ "harnesses": names, "projects": rows, "totals": totals })
459}
460
461/// `GET /policies`: who carries which policy, who carries none, and every
462/// rule recorded as not enforced.
463pub fn policies(models: &[ProjectModel]) -> Value {
464    let mut grouped: BTreeMap<&str, Vec<(&ProjectModel, Vec<&InventoryRow>)>> = BTreeMap::new();
465    for model in models {
466        let mut own: BTreeMap<&str, Vec<&InventoryRow>> = BTreeMap::new();
467        for row in &model.snapshot.rows {
468            if row.capability_type == "policy" {
469                own.entry(row.capability_id.as_str()).or_default().push(row);
470            }
471        }
472        for (id, rows) in own {
473            grouped.entry(id).or_default().push((model, rows));
474        }
475    }
476    let policies: Vec<Value> = grouped
477        .into_iter()
478        .map(|(id, carriers)| {
479            let (versions, mixed) = version_counts(carriers.iter().flat_map(|(model, rows)| {
480                rows.iter().map(|row| (model.row.id, row.version.as_str()))
481            }));
482            let usage: Vec<Value> = carriers
483                .iter()
484                .map(|(model, rows)| {
485                    let mut identity = model.identity();
486                    identity["version"] = json!(rows.first().map(|row| row.version.as_str()));
487                    identity["targets"] =
488                        json!(order_harnesses(rows.iter().map(|row| row.target.as_str())));
489                    let newer = rows
490                        .iter()
491                        .map(|row| model.capability_row(row))
492                        .find(|detail| detail.outdated);
493                    identity["outdated"] = json!(newer.is_some());
494                    identity["latest"] = json!(newer.and_then(|detail| detail.latest));
495                    identity["gapCount"] = json!(
496                        model
497                            .snapshot
498                            .gaps
499                            .iter()
500                            .filter(|gap| gap.policy == id)
501                            .count()
502                    );
503                    identity
504                })
505                .collect();
506            json!({
507                "id": id,
508                "projectCount": carriers.len(),
509                "versions": versions,
510                "mixed": mixed,
511                "usage": usage,
512            })
513        })
514        .collect();
515    let without: Vec<Value> = models
516        .iter()
517        .filter(|model| {
518            !model
519                .snapshot
520                .rows
521                .iter()
522                .any(|row| row.capability_type == "policy")
523        })
524        .map(|model| {
525            let mut identity = model.identity();
526            identity["harnesses"] = json!(model.harnesses());
527            identity
528        })
529        .collect();
530    let gaps: Vec<Value> = models
531        .iter()
532        .flat_map(|model| model.snapshot.gaps.iter().map(|gap| gap_json(model, gap)))
533        .collect();
534    json!({ "policies": policies, "projectsWithoutPolicy": without, "gaps": gaps })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    fn model(id: i64, name: &str, report: Value) -> ProjectModel {
542        ProjectModel::new(
543            ProjectRow {
544                id,
545                repository: "github.com/acme/web".into(),
546                path: name.into(),
547                name: name.into(),
548                first_report_at: "2026-09-30T10:00:00Z".into(),
549                last_report_at: "2026-09-30T11:00:00Z".into(),
550                report_count: 1,
551            },
552            &report,
553        )
554    }
555
556    fn row(name: &str, kind: &str, target: &str, version: &str) -> Value {
557        json!({
558            "name": name, "type": kind, "target": target, "version": version,
559            "source": { "kind": "local", "path": name },
560        })
561    }
562
563    fn report(rows: Vec<Value>, statuses: Vec<Value>, outdated: Value) -> Value {
564        json!({
565            "project": { "commit": "abc", "branch": "main", "dirty": true },
566            "lockfile": { "version": 3, "capabilities": rows },
567            "check": { "valid": true, "results": statuses },
568            "outdated": outdated,
569        })
570    }
571
572    fn fixture() -> Vec<ProjectModel> {
573        let mut guard = row("guard", "policy", "codex", "1.1.0");
574        guard["unenforced_rules"] =
575            json!([{ "rule": 3, "description": "deny edit", "reason": "no native rule" }]);
576        vec![
577            model(
578                1,
579                "checkout",
580                report(
581                    vec![
582                        row("review", "skill", "claude", "2.3.0"),
583                        row("review", "skill", "cursor", "2.3.0"),
584                        guard,
585                    ],
586                    vec![
587                        json!({ "id": "review", "type": "skill", "target": "cursor", "status": "modified" }),
588                    ],
589                    json!([{ "id": "guard", "type": "policy", "target": "codex", "current": "1.1.0", "latest": "1.2.0", "status": "outdated" }]),
590                ),
591            ),
592            model(
593                2,
594                "admin",
595                report(
596                    vec![row("review", "skill", "claude", "2.1.0")],
597                    vec![],
598                    Value::Null,
599                ),
600            ),
601        ]
602    }
603
604    #[test]
605    fn projects_carry_a_status_and_counts() {
606        let value = projects(&fixture());
607        let list = value["projects"].as_array().unwrap();
608        assert_eq!(list[0]["status"], "drift");
609        assert_eq!(list[0]["driftCount"], 1);
610        assert_eq!(list[0]["capabilityCount"], 2);
611        assert_eq!(list[0]["policyGapCount"], 1);
612        assert_eq!(list[0]["outdatedCount"], 1);
613        assert_eq!(list[0]["harnesses"], json!(["claude", "codex", "cursor"]));
614        assert_eq!(list[0]["dirty"], true);
615        assert_eq!(list[1]["status"], "ok");
616        assert_eq!(value["summary"]["projects"], 2);
617        assert_eq!(value["summary"]["repositories"], 1);
618        assert_eq!(value["summary"]["capabilities"], 2);
619        assert_eq!(value["summary"]["driftCount"], 1);
620    }
621
622    #[test]
623    fn capabilities_group_versions_and_flag_mixed_ones() {
624        let value = capabilities(&fixture(), None);
625        let list = value["capabilities"].as_array().unwrap();
626        let review = list.iter().find(|item| item["id"] == "review").unwrap();
627        assert_eq!(review["mixed"], true);
628        assert_eq!(review["projectCount"], 2);
629        assert_eq!(review["versions"][0]["version"], "2.1.0");
630        assert_eq!(review["versions"][0]["projects"], 1);
631        assert_eq!(value["types"], json!(["policy", "skill"]));
632        let policies_only = capabilities(&fixture(), Some("policy"));
633        assert_eq!(policies_only["capabilities"].as_array().unwrap().len(), 1);
634    }
635
636    #[test]
637    fn one_capability_lists_where_it_is_used() {
638        let models = fixture();
639        let value = capability(&models, "skill", "review").unwrap();
640        assert_eq!(value["usage"].as_array().unwrap().len(), 3);
641        assert_eq!(value["projectCount"], 2);
642        assert!(capability(&models, "skill", "missing").is_none());
643        let guard = capability(&models, "policy", "guard").unwrap();
644        assert_eq!(guard["usage"][0]["outdated"], true);
645        assert_eq!(guard["usage"][0]["latest"], "1.2.0");
646    }
647
648    #[test]
649    fn the_harness_matrix_counts_capabilities_per_cell() {
650        let value = harnesses(&fixture());
651        assert_eq!(value["harnesses"], json!(["claude", "codex", "cursor"]));
652        assert_eq!(value["projects"][0]["counts"]["claude"], 1);
653        assert_eq!(value["projects"][1]["counts"]["codex"], Value::Null);
654        assert_eq!(value["totals"]["claude"], 2);
655    }
656
657    #[test]
658    fn policies_list_carriers_gaps_and_projects_without_one() {
659        let value = policies(&fixture());
660        assert_eq!(value["policies"][0]["id"], "guard");
661        assert_eq!(value["policies"][0]["usage"][0]["gapCount"], 1);
662        assert_eq!(value["projectsWithoutPolicy"][0]["name"], "admin");
663        assert_eq!(value["gaps"][0]["rule"], 3);
664        assert_eq!(value["gaps"][0]["target"], "codex");
665    }
666
667    #[test]
668    fn a_project_shows_capabilities_per_target() {
669        let models = fixture();
670        let value = project(&models[0], json!({ "schema": 1 }));
671        assert_eq!(value["capabilities"].as_array().unwrap().len(), 3);
672        assert_eq!(value["capabilities"][2]["status"], "modified");
673        assert_eq!(value["policyGaps"][0]["policy"], "guard");
674        assert_eq!(value["latestReport"]["schema"], 1);
675    }
676}