1use std::collections::{BTreeSet, VecDeque};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::events;
9use crate::project::Project;
10use crate::repo;
11
12pub const IMPACT_SCHEMA: &str = "vela.impact_report.v0.1";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct ImpactTarget {
16 pub r#type: String,
17 pub id: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct ImpactFrontier {
22 pub vfr_id: String,
23 pub snapshot_hash: String,
24 pub event_log_hash: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ImpactSummary {
29 pub direct_dependents: usize,
30 pub total_downstream: usize,
31 pub open_proposals: usize,
32 pub accepted_events: usize,
33 pub proof_status: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct ImpactDownstream {
38 pub finding_id: String,
39 pub depth: usize,
40 pub via_link_type: String,
41 pub via_finding_id: String,
42 pub cross_frontier: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct ImpactProposal {
47 pub id: String,
48 pub kind: String,
49 pub status: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct ImpactEvent {
54 pub id: String,
55 pub kind: String,
56 pub reason: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct ImpactReport {
61 pub schema: String,
62 pub target: ImpactTarget,
63 pub frontier: ImpactFrontier,
64 pub summary: ImpactSummary,
65 #[serde(default)]
66 pub downstream: Vec<ImpactDownstream>,
67 #[serde(default)]
68 pub proposals: Vec<ImpactProposal>,
69 #[serde(default)]
70 pub events: Vec<ImpactEvent>,
71 #[serde(default)]
72 pub caveats: Vec<String>,
73}
74
75pub fn analyze_path(
76 path: &Path,
77 finding_id: &str,
78 depth: Option<usize>,
79) -> Result<ImpactReport, String> {
80 let frontier = repo::load_from_path(path)?;
81 analyze(&frontier, finding_id, depth)
82}
83
84pub fn analyze(
85 frontier: &Project,
86 finding_id: &str,
87 depth: Option<usize>,
88) -> Result<ImpactReport, String> {
89 if !frontier
90 .findings
91 .iter()
92 .any(|finding| finding.id == finding_id)
93 {
94 return Err(format!("finding not found: {finding_id}"));
95 }
96 let max_depth = depth.unwrap_or(3).max(1);
97 let downstream = downstream(frontier, finding_id, max_depth);
98 let proposals = frontier
99 .proposals
100 .iter()
101 .filter(|proposal| proposal.target.r#type == "finding" && proposal.target.id == finding_id)
102 .map(|proposal| ImpactProposal {
103 id: proposal.id.clone(),
104 kind: proposal.kind.clone(),
105 status: proposal.status.clone(),
106 })
107 .collect::<Vec<_>>();
108 let events = frontier
109 .events
110 .iter()
111 .filter(|event| event.target.r#type == "finding" && event.target.id == finding_id)
112 .map(|event| ImpactEvent {
113 id: event.id.clone(),
114 kind: event.kind.clone(),
115 reason: event.reason.clone(),
116 })
117 .collect::<Vec<_>>();
118 let direct_dependents = downstream.iter().filter(|item| item.depth == 1).count();
119 let open_proposals = proposals
120 .iter()
121 .filter(|proposal| {
122 proposal.status == "pending_review" || proposal.status == "needs_revision"
123 })
124 .count();
125 let accepted_events = events.len();
126 Ok(ImpactReport {
127 schema: IMPACT_SCHEMA.to_string(),
128 target: ImpactTarget {
129 r#type: "finding".to_string(),
130 id: finding_id.to_string(),
131 },
132 frontier: ImpactFrontier {
133 vfr_id: frontier
134 .frontier_id
135 .clone()
136 .unwrap_or_else(|| "unknown".to_string()),
137 snapshot_hash: events::snapshot_hash(frontier),
138 event_log_hash: events::event_log_hash(&frontier.events),
139 },
140 summary: ImpactSummary {
141 direct_dependents,
142 total_downstream: downstream.len(),
143 open_proposals,
144 accepted_events,
145 proof_status: proof_status(frontier),
146 },
147 downstream,
148 proposals,
149 events,
150 caveats: vec![
151 "Impact is a read-only dependency report over declared links, not automatic confidence propagation.".to_string(),
152 ],
153 })
154}
155
156fn downstream(frontier: &Project, finding_id: &str, max_depth: usize) -> Vec<ImpactDownstream> {
157 let mut out = Vec::new();
158 let mut seen = BTreeSet::<String>::new();
159 let mut queue = VecDeque::from([(finding_id.to_string(), 0usize)]);
160 while let Some((target, depth)) = queue.pop_front() {
161 if depth >= max_depth {
162 continue;
163 }
164 for finding in &frontier.findings {
165 for link in &finding.links {
166 if !matches!(
167 link.link_type.as_str(),
168 "supports" | "depends" | "contradicts"
169 ) {
170 continue;
171 }
172 if link_target_matches(&link.target, &target) && seen.insert(finding.id.clone()) {
173 let next_depth = depth + 1;
174 out.push(ImpactDownstream {
175 finding_id: finding.id.clone(),
176 depth: next_depth,
177 via_link_type: link.link_type.clone(),
178 via_finding_id: target.clone(),
179 cross_frontier: link.target.contains("@vfr_"),
180 });
181 queue.push_back((finding.id.clone(), next_depth));
182 }
183 }
184 }
185 }
186 out.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.finding_id.cmp(&b.finding_id)));
187 out
188}
189
190fn link_target_matches(link_target: &str, target: &str) -> bool {
191 link_target == target
192 || link_target
193 .split_once('@')
194 .is_some_and(|(id, _)| id == target)
195}
196
197fn proof_status(frontier: &Project) -> String {
198 let status = frontier.proof_state.latest_packet.status.as_str();
199 match status {
200 "current" => "fresh".to_string(),
201 "stale" => "stale".to_string(),
202 _ => "unknown".to_string(),
203 }
204}