1use serde::Serialize;
2
3use crate::model::Issue;
4
5use super::graph::{GraphMetrics, IssueGraph};
6
7#[derive(Debug, Clone, Serialize)]
9pub struct WhatIfDelta {
10 pub issue_id: String,
11 pub title: String,
12 pub direct_unblocks: Vec<String>,
14 pub transitive_unblocks: Vec<String>,
16 pub estimated_days_saved: f64,
18 pub pagerank_delta: f64,
20 pub cycles_broken: usize,
22}
23
24pub fn compute_what_if(
26 issues: &[Issue],
27 graph: &IssueGraph,
28 metrics: &GraphMetrics,
29 issue_id: &str,
30) -> Option<WhatIfDelta> {
31 let issue = graph.issue(issue_id)?;
32 let title = issue.title.clone();
33
34 let dependents = graph.dependents(issue_id);
36 let mut direct_unblocks = Vec::new();
37 for dep_id in &dependents {
38 let blockers = graph.open_blockers(dep_id);
39 if blockers.len() == 1 && blockers[0] == issue_id {
41 direct_unblocks.push(dep_id.clone());
42 }
43 }
44 direct_unblocks.sort();
45
46 let modified_issues: Vec<Issue> = issues
49 .iter()
50 .map(|i| {
51 if i.id == issue_id {
52 let mut closed = i.clone();
53 closed.status = "closed".to_string();
54 closed
55 } else {
56 i.clone()
57 }
58 })
59 .collect();
60
61 let modified_graph = IssueGraph::build(&modified_issues);
62 let modified_actionable: std::collections::HashSet<String> =
63 modified_graph.actionable_ids().into_iter().collect();
64 let original_actionable: std::collections::HashSet<String> =
65 graph.actionable_ids().into_iter().collect();
66
67 let mut transitive_unblocks: Vec<String> = modified_actionable
68 .difference(&original_actionable)
69 .filter(|id| *id != issue_id)
70 .cloned()
71 .collect();
72 transitive_unblocks.sort();
73
74 let estimated_days_saved = transitive_unblocks.len() as f64 * 2.0;
77
78 let modified_metrics = modified_graph.compute_metrics();
80 let before_pr_sum: f64 = issues
81 .iter()
82 .filter(|i| i.is_open_like() && i.id != issue_id)
83 .map(|i| metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
84 .sum();
85 let after_pr_sum: f64 = modified_issues
86 .iter()
87 .filter(|i| i.is_open_like() && i.id != issue_id)
88 .map(|i| modified_metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
89 .sum();
90 let pagerank_delta = before_pr_sum - after_pr_sum;
91
92 let cycles_broken = metrics
94 .cycles
95 .iter()
96 .filter(|cycle| cycle.contains(&issue_id.to_string()))
97 .count();
98
99 Some(WhatIfDelta {
100 issue_id: issue_id.to_string(),
101 title,
102 direct_unblocks,
103 transitive_unblocks,
104 estimated_days_saved,
105 pagerank_delta,
106 cycles_broken,
107 })
108}
109
110pub fn top_what_if_deltas(
112 issues: &[Issue],
113 graph: &IssueGraph,
114 metrics: &GraphMetrics,
115 top_n: usize,
116) -> Vec<WhatIfDelta> {
117 if top_n == 0 {
118 return Vec::new();
119 }
120
121 let mut deltas: Vec<WhatIfDelta> = issues
122 .iter()
123 .filter(|i| i.is_open_like())
124 .filter_map(|i| compute_what_if(issues, graph, metrics, &i.id))
125 .collect();
126
127 deltas.sort_by(|a, b| {
129 b.transitive_unblocks
130 .len()
131 .cmp(&a.transitive_unblocks.len())
132 .then_with(|| b.direct_unblocks.len().cmp(&a.direct_unblocks.len()))
133 .then_with(|| a.issue_id.cmp(&b.issue_id))
134 });
135 deltas.truncate(top_n);
136 deltas
137}
138
139#[cfg(test)]
140mod tests {
141 use crate::model::{Dependency, Issue};
142
143 use super::*;
144
145 fn make_issue(id: &str, status: &str) -> Issue {
146 Issue {
147 id: id.to_string(),
148 title: format!("Issue {id}"),
149 status: status.to_string(),
150 issue_type: "task".to_string(),
151 priority: 2,
152 ..Issue::default()
153 }
154 }
155
156 fn make_blocked(id: &str, depends_on: &str) -> Issue {
157 Issue {
158 id: id.to_string(),
159 title: format!("Issue {id}"),
160 status: "blocked".to_string(),
161 issue_type: "task".to_string(),
162 priority: 2,
163 dependencies: vec![Dependency {
164 issue_id: id.to_string(),
165 depends_on_id: depends_on.to_string(),
166 dep_type: "blocks".to_string(),
167 ..Dependency::default()
168 }],
169 ..Issue::default()
170 }
171 }
172
173 #[test]
174 fn what_if_single_blocker_unblocks_dependent() {
175 let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
176 let graph = IssueGraph::build(&issues);
177 let metrics = graph.compute_metrics();
178
179 let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
180 assert_eq!(delta.issue_id, "A");
181 assert_eq!(delta.direct_unblocks, vec!["B"]);
182 assert!(delta.transitive_unblocks.contains(&"B".to_string()));
183 assert!(delta.estimated_days_saved >= 2.0);
184 }
185
186 #[test]
187 fn what_if_chain_produces_transitive_unblocks() {
188 let issues = vec![
190 make_issue("A", "open"),
191 make_blocked("B", "A"),
192 Issue {
193 id: "C".to_string(),
194 title: "Issue C".to_string(),
195 status: "blocked".to_string(),
196 issue_type: "task".to_string(),
197 priority: 2,
198 dependencies: vec![Dependency {
199 issue_id: "C".to_string(),
200 depends_on_id: "B".to_string(),
201 dep_type: "blocks".to_string(),
202 ..Dependency::default()
203 }],
204 ..Issue::default()
205 },
206 ];
207 let graph = IssueGraph::build(&issues);
208 let metrics = graph.compute_metrics();
209
210 let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
211 assert_eq!(delta.direct_unblocks, vec!["B"]);
212 assert!(delta.transitive_unblocks.contains(&"B".to_string()));
214 }
215
216 #[test]
217 fn what_if_no_dependents_returns_empty_unblocks() {
218 let issues = vec![make_issue("A", "open"), make_issue("B", "open")];
219 let graph = IssueGraph::build(&issues);
220 let metrics = graph.compute_metrics();
221
222 let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
223 assert!(delta.direct_unblocks.is_empty());
224 assert!(delta.transitive_unblocks.is_empty());
225 assert!((delta.estimated_days_saved - 0.0).abs() < 1e-6);
226 }
227
228 #[test]
229 fn what_if_nonexistent_issue_returns_none() {
230 let issues = vec![make_issue("A", "open")];
231 let graph = IssueGraph::build(&issues);
232 let metrics = graph.compute_metrics();
233
234 assert!(compute_what_if(&issues, &graph, &metrics, "X").is_none());
235 }
236
237 #[test]
238 fn what_if_empty_graph() {
239 let issues: Vec<Issue> = vec![];
240 let graph = IssueGraph::build(&issues);
241 let metrics = graph.compute_metrics();
242
243 assert!(compute_what_if(&issues, &graph, &metrics, "A").is_none());
244 let deltas = top_what_if_deltas(&issues, &graph, &metrics, 5);
245 assert!(deltas.is_empty());
246 }
247
248 #[test]
249 fn what_if_single_node_graph() {
250 let issues = vec![make_issue("A", "open")];
251 let graph = IssueGraph::build(&issues);
252 let metrics = graph.compute_metrics();
253
254 let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
255 assert!(delta.direct_unblocks.is_empty());
256 assert!(delta.transitive_unblocks.is_empty());
257 assert!((delta.estimated_days_saved - 0.0).abs() < 1e-6);
258 }
259
260 #[test]
261 fn top_what_if_deltas_sorts_by_impact() {
262 let issues = vec![
264 make_issue("A", "open"),
265 make_issue("B", "open"),
266 make_issue("C", "open"),
267 make_blocked("D1", "A"),
268 make_blocked("D2", "A"),
269 make_blocked("D3", "A"),
270 make_blocked("E1", "B"),
271 ];
272 let graph = IssueGraph::build(&issues);
273 let metrics = graph.compute_metrics();
274
275 let deltas = top_what_if_deltas(&issues, &graph, &metrics, 3);
276 assert!(!deltas.is_empty());
277 assert_eq!(deltas[0].issue_id, "A");
279 assert_eq!(deltas[1].issue_id, "B");
281 }
282
283 #[test]
284 fn what_if_respects_top_n_limit() {
285 let issues = vec![
286 make_issue("A", "open"),
287 make_issue("B", "open"),
288 make_issue("C", "open"),
289 ];
290 let graph = IssueGraph::build(&issues);
291 let metrics = graph.compute_metrics();
292
293 let deltas = top_what_if_deltas(&issues, &graph, &metrics, 2);
294 assert!(deltas.len() <= 2);
295 }
296
297 #[test]
298 fn what_if_zero_top_n_returns_no_results() {
299 let issues = vec![
300 make_issue("A", "open"),
301 make_issue("B", "open"),
302 make_blocked("C", "A"),
303 ];
304 let graph = IssueGraph::build(&issues);
305 let metrics = graph.compute_metrics();
306
307 let deltas = top_what_if_deltas(&issues, &graph, &metrics, 0);
308 assert!(deltas.is_empty());
309 }
310
311 #[test]
312 fn what_if_cycle_detection() {
313 let issues = vec![
314 Issue {
315 id: "A".to_string(),
316 title: "In cycle".to_string(),
317 status: "open".to_string(),
318 issue_type: "task".to_string(),
319 dependencies: vec![Dependency {
320 depends_on_id: "B".to_string(),
321 dep_type: "blocks".to_string(),
322 ..Dependency::default()
323 }],
324 ..Issue::default()
325 },
326 Issue {
327 id: "B".to_string(),
328 title: "In cycle".to_string(),
329 status: "open".to_string(),
330 issue_type: "task".to_string(),
331 dependencies: vec![Dependency {
332 depends_on_id: "A".to_string(),
333 dep_type: "blocks".to_string(),
334 ..Dependency::default()
335 }],
336 ..Issue::default()
337 },
338 ];
339 let graph = IssueGraph::build(&issues);
340 let metrics = graph.compute_metrics();
341
342 let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
343 assert!(
344 delta.cycles_broken > 0,
345 "A is in a cycle, should break at least 1"
346 );
347 }
348
349 #[test]
350 fn what_if_serializes_to_json() {
351 let delta = WhatIfDelta {
352 issue_id: "A".to_string(),
353 title: "Test".to_string(),
354 direct_unblocks: vec!["B".to_string()],
355 transitive_unblocks: vec!["B".to_string(), "C".to_string()],
356 estimated_days_saved: 4.0,
357 pagerank_delta: 0.05,
358 cycles_broken: 0,
359 };
360
361 let json = serde_json::to_value(&delta).unwrap();
362 assert_eq!(json["issue_id"], "A");
363 assert_eq!(json["direct_unblocks"], serde_json::json!(["B"]));
364 assert_eq!(json["estimated_days_saved"], 4.0);
365 assert_eq!(json["cycles_broken"], 0);
366 }
367
368 #[test]
369 fn what_if_large_graph_correctness() {
370 let mut issues = vec![make_issue("I0", "open")];
372 for i in 1..100 {
373 issues.push(make_blocked(&format!("I{i}"), &format!("I{}", i - 1)));
374 }
375
376 let graph = IssueGraph::build(&issues);
377 let metrics = graph.compute_metrics();
378
379 let delta = compute_what_if(&issues, &graph, &metrics, "I0").unwrap();
381 assert_eq!(delta.direct_unblocks, vec!["I1"]);
382 assert!(delta.transitive_unblocks.contains(&"I1".to_string()));
384 assert!(
385 !delta.transitive_unblocks.contains(&"I2".to_string()),
386 "I2 should still be blocked by I1"
387 );
388
389 let deltas = top_what_if_deltas(&issues, &graph, &metrics, 5);
391 assert!(!deltas.is_empty());
392 for d in &deltas {
394 assert!(d.estimated_days_saved >= 0.0);
395 }
396 }
397}