1use 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
16const HARNESS_ORDER: &[&str] = &["claude", "codex", "cursor", "opencode", "open-agents"];
19
20#[derive(Debug, Clone)]
22struct Newer {
23 latest: Option<String>,
24}
25
26#[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#[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 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 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
222pub 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
269pub 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
318pub 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
382pub 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
425pub 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
461pub 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}