1use std::collections::{BTreeMap, BTreeSet};
35
36use anyhow::{Context, Result, bail};
37use shipper_types::{PackageReceipt, PackageState, Receipt};
38
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct YankEntry {
46 pub name: String,
47 pub version: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub reason: Option<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PlanYankFilter {
59 AllPublished,
63 CompromisedOnly,
67}
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct YankPlan {
72 pub plan_id: String,
73 pub registry: String,
74 #[serde(default = "unknown_filter")]
78 pub filter: std::borrow::Cow<'static, str>,
79 pub entries: Vec<YankEntry>,
80}
81
82fn unknown_filter() -> std::borrow::Cow<'static, str> {
83 std::borrow::Cow::Borrowed("unknown")
84}
85
86fn include(receipt: &PackageReceipt, filter: PlanYankFilter) -> bool {
87 match filter {
88 PlanYankFilter::AllPublished => matches!(receipt.state, PackageState::Published),
89 PlanYankFilter::CompromisedOnly => receipt.compromised_at.is_some(),
90 }
91}
92
93pub fn build_plan(receipt: &Receipt, filter: PlanYankFilter) -> YankPlan {
100 let mut entries: Vec<YankEntry> = receipt
101 .packages
102 .iter()
103 .filter(|p| include(p, filter))
104 .map(|p| YankEntry {
105 name: p.name.clone(),
106 version: p.version.clone(),
107 reason: p.compromised_by.clone(),
108 })
109 .collect();
110 entries.reverse();
111
112 YankPlan {
113 plan_id: receipt.plan_id.clone(),
114 registry: receipt.registry.name.clone(),
115 filter: std::borrow::Cow::Borrowed(match filter {
116 PlanYankFilter::AllPublished => "all_published",
117 PlanYankFilter::CompromisedOnly => "compromised_only",
118 }),
119 entries,
120 }
121}
122
123pub fn build_plan_from_starting_crate(
139 receipt: &Receipt,
140 dependency_graph: &BTreeMap<String, Vec<String>>,
141 starting_crate: &str,
142 reason: Option<String>,
143) -> Result<YankPlan> {
144 if !receipt.packages.iter().any(|p| p.name == starting_crate) {
145 bail!(
146 "starting crate '{starting_crate}' is not in this receipt; \
147 available packages: {}",
148 receipt
149 .packages
150 .iter()
151 .map(|p| p.name.as_str())
152 .collect::<Vec<_>>()
153 .join(", ")
154 );
155 }
156
157 let mut affected: BTreeSet<String> = BTreeSet::new();
160 affected.insert(starting_crate.to_string());
161 let mut frontier: Vec<String> = vec![starting_crate.to_string()];
162 while let Some(current) = frontier.pop() {
163 for (dependent, deps) in dependency_graph.iter() {
164 if deps.iter().any(|d| d == ¤t) && affected.insert(dependent.clone()) {
165 frontier.push(dependent.clone());
166 }
167 }
168 }
169
170 let mut entries: Vec<YankEntry> = receipt
175 .packages
176 .iter()
177 .filter(|p| affected.contains(&p.name))
178 .filter(|p| matches!(p.state, PackageState::Published))
179 .map(|p| YankEntry {
180 name: p.name.clone(),
181 version: p.version.clone(),
182 reason: reason.clone().or_else(|| p.compromised_by.clone()),
187 })
188 .collect();
189 entries.reverse();
190
191 Ok(YankPlan {
192 plan_id: receipt.plan_id.clone(),
193 registry: receipt.registry.name.clone(),
194 filter: std::borrow::Cow::Borrowed("starting_crate"),
195 entries,
196 })
197}
198
199pub fn load_plan_from_path(path: &std::path::Path) -> Result<YankPlan> {
206 let raw = std::fs::read_to_string(path)
207 .with_context(|| format!("failed to read yank plan at {}", path.display()))?;
208 serde_json::from_str(&raw)
209 .with_context(|| format!("failed to parse yank plan at {}", path.display()))
210}
211
212pub fn load_receipt_from_path(path: &std::path::Path) -> Result<Receipt> {
215 let raw = std::fs::read_to_string(path)
216 .with_context(|| format!("failed to read receipt at {}", path.display()))?;
217 serde_json::from_str(&raw)
218 .with_context(|| format!("failed to parse receipt at {}", path.display()))
219}
220
221pub fn render_text(plan: &YankPlan) -> String {
225 let mut out = String::new();
226 out.push_str(&format!(
227 "# yank plan (reverse topological) — registry={}, plan_id={}, filter={}\n",
228 plan.registry, plan.plan_id, plan.filter
229 ));
230 out.push_str(&format!("# {} entries\n", plan.entries.len()));
231 if plan.entries.is_empty() {
232 out.push_str("# (no packages match the filter; nothing to yank)\n");
233 return out;
234 }
235 for (i, e) in plan.entries.iter().enumerate() {
236 let reason = e
237 .reason
238 .as_deref()
239 .map(|r| format!(" # {r}"))
240 .unwrap_or_default();
241 out.push_str(&format!(
242 "{:>3}. shipper yank --crate {} --version {} --reason <REASON>{reason}\n",
243 i + 1,
244 e.name,
245 e.version
246 ));
247 }
248 out
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use chrono::Utc;
255 use shipper_types::{
256 EnvironmentFingerprint, PackageEvidence, PackageReceipt, PackageState, Receipt, Registry,
257 };
258 use std::path::PathBuf;
259
260 fn pkg(name: &str, state: PackageState, compromised: Option<&str>) -> PackageReceipt {
261 PackageReceipt {
262 name: name.to_string(),
263 version: "0.1.0".to_string(),
264 attempts: 1,
265 state,
266 started_at: Utc::now(),
267 finished_at: Utc::now(),
268 duration_ms: 10,
269 evidence: PackageEvidence {
270 attempts: vec![],
271 readiness_checks: vec![],
272 },
273 compromised_at: compromised.map(|_| Utc::now()),
274 compromised_by: compromised.map(str::to_string),
275 superseded_by: None,
276 }
277 }
278
279 fn sample_receipt(packages: Vec<PackageReceipt>) -> Receipt {
280 Receipt {
281 receipt_version: "shipper.receipt.v2".to_string(),
282 plan_id: "plan-sample".to_string(),
283 registry: Registry::crates_io(),
284 started_at: Utc::now(),
285 finished_at: Utc::now(),
286 packages,
287 event_log_path: PathBuf::from(".shipper/events.jsonl"),
288 git_context: None,
289 environment: EnvironmentFingerprint {
290 shipper_version: "0.3.0".into(),
291 cargo_version: None,
292 rust_version: None,
293 os: "test".into(),
294 arch: "x86_64".into(),
295 },
296 auth_evidence: None,
297 }
298 }
299
300 #[test]
301 fn reverses_publish_order_for_all_published() {
302 let r = sample_receipt(vec![
303 pkg("a", PackageState::Published, None),
304 pkg("b", PackageState::Published, None),
305 pkg("c", PackageState::Published, None),
306 ]);
307 let plan = build_plan(&r, PlanYankFilter::AllPublished);
308 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
309 assert_eq!(names, vec!["c", "b", "a"]);
310 }
311
312 #[test]
313 fn excludes_failed_and_skipped_packages() {
314 let r = sample_receipt(vec![
315 pkg("a", PackageState::Published, None),
316 pkg(
317 "b",
318 PackageState::Failed {
319 class: shipper_types::ErrorClass::Permanent,
320 message: "nope".into(),
321 },
322 None,
323 ),
324 pkg(
325 "c",
326 PackageState::Skipped {
327 reason: "already there".into(),
328 },
329 None,
330 ),
331 ]);
332 let plan = build_plan(&r, PlanYankFilter::AllPublished);
333 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
334 assert_eq!(names, vec!["a"]);
335 }
336
337 #[test]
338 fn compromised_only_filter_drops_healthy_packages() {
339 let r = sample_receipt(vec![
340 pkg("a", PackageState::Published, None),
341 pkg("b", PackageState::Published, Some("CVE-2026-0001")),
342 pkg("c", PackageState::Published, None),
343 ]);
344 let plan = build_plan(&r, PlanYankFilter::CompromisedOnly);
345 assert_eq!(plan.entries.len(), 1);
346 assert_eq!(plan.entries[0].name, "b");
347 assert_eq!(plan.entries[0].reason.as_deref(), Some("CVE-2026-0001"));
348 }
349
350 #[test]
351 fn empty_plan_on_empty_receipt() {
352 let r = sample_receipt(vec![]);
353 let plan = build_plan(&r, PlanYankFilter::AllPublished);
354 assert!(plan.entries.is_empty());
355 assert!(render_text(&plan).contains("nothing to yank"));
356 }
357
358 #[test]
359 fn text_render_uses_reverse_topo_order_with_indices() {
360 let r = sample_receipt(vec![
361 pkg("a", PackageState::Published, None),
362 pkg("b", PackageState::Published, None),
363 ]);
364 let out = render_text(&build_plan(&r, PlanYankFilter::AllPublished));
365 let b_pos = out.find("shipper yank --crate b").unwrap();
367 let a_pos = out.find("shipper yank --crate a").unwrap();
368 assert!(
369 b_pos < a_pos,
370 "b must come before a in reverse topo:\n{out}"
371 );
372 assert!(out.starts_with("# yank plan"));
373 }
374
375 #[test]
381 fn starting_crate_walks_all_transitive_dependents_in_reverse_topo() {
382 let r = sample_receipt(vec![
383 pkg("a", PackageState::Published, None),
384 pkg("b", PackageState::Published, None),
385 pkg("c", PackageState::Published, None),
386 ]);
387 let mut deps = BTreeMap::new();
388 deps.insert("a".to_string(), vec![]);
389 deps.insert("b".to_string(), vec!["a".to_string()]);
390 deps.insert("c".to_string(), vec!["a".to_string(), "b".to_string()]);
391
392 let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
393 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
394 assert_eq!(names, vec!["c", "b", "a"]);
395 assert_eq!(plan.filter, "starting_crate");
396 }
397
398 #[test]
401 fn starting_crate_ignores_unrelated_crates() {
402 let r = sample_receipt(vec![
403 pkg("a", PackageState::Published, None),
404 pkg("b", PackageState::Published, None),
405 pkg("z", PackageState::Published, None),
406 ]);
407 let mut deps = BTreeMap::new();
408 deps.insert("a".to_string(), vec![]);
409 deps.insert("b".to_string(), vec!["a".to_string()]);
410 deps.insert("z".to_string(), vec![]); let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
413 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
414 assert_eq!(names, vec!["b", "a"]);
415 assert!(!names.contains(&"z".to_string()));
416 }
417
418 #[test]
419 fn starting_crate_skips_non_published_entries() {
420 let r = sample_receipt(vec![
421 pkg("a", PackageState::Published, None),
422 pkg(
423 "b",
424 PackageState::Failed {
425 class: shipper_types::ErrorClass::Permanent,
426 message: "nope".into(),
427 },
428 None,
429 ),
430 ]);
431 let mut deps = BTreeMap::new();
432 deps.insert("b".to_string(), vec!["a".to_string()]);
433
434 let plan = build_plan_from_starting_crate(&r, &deps, "a", None).expect("plan");
435 let names: Vec<_> = plan.entries.iter().map(|e| e.name.clone()).collect();
438 assert_eq!(names, vec!["a"]);
439 }
440
441 #[test]
442 fn starting_crate_applies_explicit_reason_to_every_entry() {
443 let r = sample_receipt(vec![
444 pkg("a", PackageState::Published, None),
445 pkg("b", PackageState::Published, None),
446 ]);
447 let mut deps = BTreeMap::new();
448 deps.insert("b".to_string(), vec!["a".to_string()]);
449
450 let plan =
451 build_plan_from_starting_crate(&r, &deps, "a", Some("CVE-2026-0001".to_string()))
452 .expect("plan");
453 assert_eq!(plan.entries.len(), 2);
454 for entry in &plan.entries {
455 assert_eq!(entry.reason.as_deref(), Some("CVE-2026-0001"));
456 }
457 }
458
459 #[test]
460 fn starting_crate_errors_when_not_in_receipt() {
461 let r = sample_receipt(vec![pkg("a", PackageState::Published, None)]);
462 let deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
463 let err =
464 build_plan_from_starting_crate(&r, &deps, "bogus", None).expect_err("should error");
465 let msg = format!("{err:#}");
466 assert!(msg.contains("not in this receipt"), "err: {msg}");
467 }
468
469 #[test]
472 fn yank_plan_json_roundtrips_via_load_plan_from_path() {
473 let td = tempfile::tempdir().expect("tempdir");
474 let r = sample_receipt(vec![
475 pkg("a", PackageState::Published, Some("CVE-1")),
476 pkg("b", PackageState::Published, None),
477 ]);
478 let plan = build_plan(&r, PlanYankFilter::AllPublished);
479 let path = td.path().join("yank-plan.json");
480 let raw = serde_json::to_string_pretty(&plan).expect("serialize");
481 std::fs::write(&path, raw).expect("write");
482
483 let loaded = load_plan_from_path(&path).expect("load");
484 assert_eq!(loaded.plan_id, plan.plan_id);
485 assert_eq!(loaded.registry, plan.registry);
486 assert_eq!(loaded.entries.len(), plan.entries.len());
487 assert_eq!(loaded.entries[0].name, "b");
489 assert_eq!(loaded.entries[1].name, "a");
490 assert_eq!(loaded.entries[1].reason.as_deref(), Some("CVE-1"));
492 }
493
494 #[test]
495 fn load_plan_from_path_errors_on_missing_file() {
496 let err = load_plan_from_path(std::path::Path::new("/definitely/not/there.json"))
497 .expect_err("should fail");
498 assert!(format!("{err:#}").contains("failed to read yank plan"));
499 }
500
501 #[test]
502 fn load_plan_from_path_errors_on_malformed_json() {
503 let td = tempfile::tempdir().expect("tempdir");
504 let path = td.path().join("malformed.json");
505 std::fs::write(&path, "{ not valid json ").expect("write");
506 let err = load_plan_from_path(&path).expect_err("should fail");
507 assert!(format!("{err:#}").contains("failed to parse yank plan"));
508 }
509}