use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use cleanlib_client::{config, transport, types};
use super::lockfile::{self, LockNode, LockfileKind};
use super::risk_rules::{self, RiskAcceptRule};
#[derive(Debug, Clone, PartialEq)]
pub struct FixEntry {
pub ecosystem: String,
pub package: String,
pub from_version: String,
pub to_version: String,
pub reason: String,
pub line: Option<usize>,
pub direct: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkippedEntry {
pub package: String,
pub version: String,
pub reason: SkipReason,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SkipReason {
RiskAccepted { rule: String },
NotAllowListed,
Allowed,
NoRecommendation,
LookupError { error: String },
}
#[derive(Debug, Clone, PartialEq)]
pub struct FixPlan {
pub lockfile: PathBuf,
pub kind: Option<LockfileKind>,
pub entries: Vec<FixEntry>,
pub skipped: Vec<SkippedEntry>,
}
#[derive(Debug, Clone)]
pub struct FixArgs {
pub lockfile: PathBuf,
pub apply: bool,
pub risk_accept: Option<PathBuf>,
pub allow_list: Option<PathBuf>,
pub auto_pr: bool,
pub pr_remote: Option<String>,
pub output: String,
}
pub async fn run(args: FixArgs) -> Result<()> {
let content = std::fs::read_to_string(&args.lockfile)
.with_context(|| format!("read lockfile: {}", args.lockfile.display()))?;
let kind = lockfile::identify_lockfile(&args.lockfile);
if kind.is_none() {
anyhow::bail!(
"unrecognized lockfile: {} (supported: package-lock.json, Cargo.lock, go.sum, poetry.lock)",
args.lockfile.display()
);
}
let nodes = lockfile::parse_lockfile(&args.lockfile, &content);
if nodes.is_empty() {
anyhow::bail!(
"no dependency nodes parsed from {} — file empty or unexpected shape",
args.lockfile.display()
);
}
let allow_list = load_allow_list(args.allow_list.as_deref())?;
let risk_rules = match &args.risk_accept {
Some(p) => risk_rules::load_rules(p)?,
None => Vec::new(),
};
let cfg_path = config::default_path();
let cfg = config::load_with_env_overrides(cfg_path.as_deref())?;
let client = transport::Client::from_config(&cfg)?;
let plan = build_plan(
&args.lockfile,
kind,
&nodes,
&allow_list,
&risk_rules,
&client,
)
.await?;
match args.output.as_str() {
"json" => println!("{}", render_json(&plan)?),
_ => render_plan_terminal(&plan, args.apply),
}
if args.auto_pr {
let bundle = render_pr_bundle(&plan, args.pr_remote.as_deref());
println!("\n--- fix plan (auto-pr bundle) ---");
println!("{}", bundle);
return Ok(());
}
if !args.apply {
if !plan.entries.is_empty() {
std::process::exit(4);
}
return Ok(());
}
apply_plan(&args.lockfile, &content, &plan)?;
Ok(())
}
async fn build_plan(
lockfile_path: &Path,
kind: Option<LockfileKind>,
nodes: &[LockNode],
allow_list: &Option<HashSet<String>>,
risk_rules: &[RiskAcceptRule],
client: &transport::Client,
) -> Result<FixPlan> {
let mut verdicts: Vec<(LockNode, Result<types::Verdict, String>)> = Vec::with_capacity(nodes.len());
for node in nodes {
let res = client
.fetch_verdict(&node.ecosystem, &node.name, &node.version)
.await
.map_err(|e| e.to_string());
verdicts.push((node.clone(), res));
}
Ok(plan_from_verdicts(
lockfile_path,
kind,
verdicts,
allow_list,
risk_rules,
))
}
pub fn plan_from_verdicts(
lockfile_path: &Path,
kind: Option<LockfileKind>,
verdicts: Vec<(LockNode, Result<types::Verdict, String>)>,
allow_list: &Option<HashSet<String>>,
risk_rules: &[RiskAcceptRule],
) -> FixPlan {
let mut entries: Vec<FixEntry> = Vec::new();
let mut skipped: Vec<SkippedEntry> = Vec::new();
for (node, verdict) in verdicts {
if let Some(rule) = risk_rules
.iter()
.find(|r| r.matches(&node.name, &node.version))
{
skipped.push(SkippedEntry {
package: node.name,
version: node.version,
reason: SkipReason::RiskAccepted {
rule: format!("{} {}", rule.package, rule.version_range),
},
});
continue;
}
if let Some(allow) = allow_list {
if !allow.contains(&node.name) {
skipped.push(SkippedEntry {
package: node.name,
version: node.version,
reason: SkipReason::NotAllowListed,
});
continue;
}
}
match verdict {
Err(e) => {
skipped.push(SkippedEntry {
package: node.name,
version: node.version,
reason: SkipReason::LookupError { error: e },
});
}
Ok(v) => {
let decision = v
.decision
.as_deref()
.unwrap_or(v.verdict.as_str())
.to_string();
let is_block = matches!(
decision.as_str(),
"DENY" | "VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" | "WARN"
);
if !is_block {
skipped.push(SkippedEntry {
package: node.name,
version: node.version,
reason: SkipReason::Allowed,
});
continue;
}
match extract_recommended_version(&v) {
Some(to_v) => entries.push(FixEntry {
ecosystem: node.ecosystem,
package: node.name,
from_version: node.version,
to_version: to_v,
reason: shorten_reason(&v.reasoning, &decision),
line: node.line,
direct: node.direct,
}),
None => skipped.push(SkippedEntry {
package: node.name,
version: node.version,
reason: SkipReason::NoRecommendation,
}),
}
}
}
}
entries.sort_by(|a, b| {
a.ecosystem
.cmp(&b.ecosystem)
.then(a.package.cmp(&b.package))
.then(a.from_version.cmp(&b.from_version))
});
FixPlan {
lockfile: lockfile_path.to_path_buf(),
kind,
entries,
skipped,
}
}
fn extract_recommended_version(v: &types::Verdict) -> Option<String> {
for action in &v.suggested_actions {
for sep in ['=', ':'] {
if let Some((key, val)) = action.split_once(sep) {
if key.trim().eq_ignore_ascii_case("recommended_version") {
let candidate = val.trim();
if !candidate.is_empty() {
return Some(candidate.to_string());
}
}
}
}
}
None
}
fn shorten_reason(reasoning: &str, decision: &str) -> String {
if reasoning.trim().is_empty() {
return decision.to_string();
}
let one_line = reasoning.replace('\n', " ").replace('\r', " ");
if one_line.len() > 200 {
format!("{}: {}...", decision, &one_line[..200])
} else {
format!("{}: {}", decision, one_line.trim())
}
}
fn load_allow_list(path: Option<&Path>) -> Result<Option<HashSet<String>>> {
let Some(path) = path else {
return Ok(None);
};
let content = std::fs::read_to_string(path)
.with_context(|| format!("read allow-list: {}", path.display()))?;
let allow: HashSet<String> = content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.to_string())
.collect();
Ok(Some(allow))
}
fn render_plan_terminal(plan: &FixPlan, apply: bool) {
let banner = if apply {
"cleanlib fix — APPLY MODE"
} else {
"cleanlib fix — DRY RUN (default; pass --apply to write)"
};
println!("{}", banner);
println!("lockfile: {}", plan.lockfile.display());
println!(
"planned: {} · skipped: {}",
plan.entries.len(),
plan.skipped.len()
);
if plan.entries.is_empty() {
println!("no upgrades to apply.");
} else {
println!("\n-- blast radius --");
for e in &plan.entries {
let line = e
.line
.map(|n| format!(":{}", n))
.unwrap_or_else(|| String::from(":?"));
let direct_tag = if e.direct { "direct" } else { "transitive" };
println!(
" {}{} [{}] {} {} → {} — {}",
plan.lockfile.display(),
line,
direct_tag,
e.package,
e.from_version,
e.to_version,
e.reason
);
}
}
if !plan.skipped.is_empty() {
println!("\n-- skipped --");
for s in &plan.skipped {
let reason = match &s.reason {
SkipReason::RiskAccepted { rule } => format!("risk-accepted ({})", rule),
SkipReason::NotAllowListed => "not in --allow-list".to_string(),
SkipReason::Allowed => "verdict ALLOW".to_string(),
SkipReason::NoRecommendation => "no upgrade target".to_string(),
SkipReason::LookupError { error } => format!("lookup error: {}", error),
};
println!(" {}@{} — {}", s.package, s.version, reason);
}
}
}
fn render_json(plan: &FixPlan) -> Result<String> {
let mut entries = Vec::new();
for e in &plan.entries {
entries.push(serde_json::json!({
"ecosystem": e.ecosystem,
"package": e.package,
"from_version": e.from_version,
"to_version": e.to_version,
"reason": e.reason,
"line": e.line,
"direct": e.direct,
}));
}
let mut skipped = Vec::new();
for s in &plan.skipped {
let (kind, detail) = match &s.reason {
SkipReason::RiskAccepted { rule } => ("risk_accepted", rule.clone()),
SkipReason::NotAllowListed => ("not_allow_listed", String::new()),
SkipReason::Allowed => ("allowed", String::new()),
SkipReason::NoRecommendation => ("no_recommendation", String::new()),
SkipReason::LookupError { error } => ("lookup_error", error.clone()),
};
skipped.push(serde_json::json!({
"package": s.package,
"version": s.version,
"reason": kind,
"detail": detail,
}));
}
let out = serde_json::json!({
"lockfile": plan.lockfile.display().to_string(),
"entries": entries,
"skipped": skipped,
});
Ok(serde_json::to_string_pretty(&out)?)
}
pub fn apply_plan(lockfile_path: &Path, content: &str, plan: &FixPlan) -> Result<()> {
if plan.entries.is_empty() {
eprintln!("no entries to apply — lockfile unchanged.");
return Ok(());
}
let updated = rewrite_lockfile(content, plan)?;
if updated == content {
eprintln!(
"warning: apply produced no textual change to {}",
lockfile_path.display()
);
return Ok(());
}
backup_lockfile(lockfile_path)?;
std::fs::write(lockfile_path, &updated)
.with_context(|| format!("write updated lockfile: {}", lockfile_path.display()))?;
eprintln!(
"wrote {} ({} entries applied, {} bytes)",
lockfile_path.display(),
plan.entries.len(),
updated.len()
);
Ok(())
}
fn backup_lockfile(path: &Path) -> Result<()> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ext = path
.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default();
let backup = path.with_extension(format!("{}cleanlib-backup-{}", ext, ts));
std::fs::copy(path, &backup).with_context(|| {
format!("backup {} → {}", path.display(), backup.display())
})?;
eprintln!("backed up existing {} → {}", path.display(), backup.display());
Ok(())
}
pub fn rewrite_lockfile(content: &str, plan: &FixPlan) -> Result<String> {
match plan.kind {
Some(LockfileKind::PackageLockJson) => rewrite_package_lock_json(content, plan),
Some(LockfileKind::CargoLock) => Ok(rewrite_toml_package_array(content, plan)),
Some(LockfileKind::PoetryLock) => Ok(rewrite_toml_package_array(content, plan)),
Some(LockfileKind::GoSum) => Ok(rewrite_go_sum(content, plan)),
None => anyhow::bail!("cannot apply — lockfile kind was not identified"),
}
}
fn rewrite_package_lock_json(content: &str, plan: &FixPlan) -> Result<String> {
let mut json: serde_json::Value = serde_json::from_str(content)
.with_context(|| "package-lock.json is not valid JSON — refusing to write")?;
let by_name: std::collections::HashMap<&str, &FixEntry> = plan
.entries
.iter()
.filter(|e| e.ecosystem == "npm")
.map(|e| (e.package.as_str(), e))
.collect();
if let Some(packages) = json.get_mut("packages").and_then(|v| v.as_object_mut()) {
for (path, entry) in packages.iter_mut() {
if path.is_empty() {
continue;
}
let name = match path.rsplit_once("node_modules/") {
Some((_, tail)) if !tail.is_empty() => tail,
_ => continue,
};
if let Some(fix) = by_name.get(name) {
let entry_obj = match entry.as_object_mut() {
Some(o) => o,
None => continue,
};
let current = entry_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("");
if current == fix.from_version {
entry_obj.insert(
"version".to_string(),
serde_json::Value::String(fix.to_version.clone()),
);
}
}
}
}
if let Some(deps) = json.get_mut("dependencies").and_then(|v| v.as_object_mut()) {
rewrite_v1_deps(deps, &by_name);
}
let mut out = serde_json::to_string_pretty(&json)?;
out.push('\n');
Ok(out)
}
fn rewrite_v1_deps(
deps: &mut serde_json::Map<String, serde_json::Value>,
by_name: &std::collections::HashMap<&str, &FixEntry>,
) {
for (name, entry) in deps.iter_mut() {
let obj = match entry.as_object_mut() {
Some(o) => o,
None => continue,
};
if let Some(fix) = by_name.get(name.as_str()) {
let current = obj.get("version").and_then(|v| v.as_str()).unwrap_or("");
if current == fix.from_version {
obj.insert(
"version".to_string(),
serde_json::Value::String(fix.to_version.clone()),
);
}
}
if let Some(nested) = obj.get_mut("dependencies").and_then(|v| v.as_object_mut()) {
rewrite_v1_deps(nested, by_name);
}
}
}
fn rewrite_toml_package_array(content: &str, plan: &FixPlan) -> String {
let by_name: std::collections::HashMap<&str, &FixEntry> = plan
.entries
.iter()
.map(|e| (e.package.as_str(), e))
.collect();
let mut out = String::with_capacity(content.len() + 64);
let mut cur_name: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed == "[[package]]" {
cur_name = None;
out.push_str(line);
out.push('\n');
continue;
}
if let Some(rest) = trimmed.strip_prefix("name") {
if let Some(n) = extract_toml_string_local(rest) {
cur_name = Some(n);
}
out.push_str(line);
out.push('\n');
continue;
}
if let Some(rest) = trimmed.strip_prefix("version") {
if let Some(current) = extract_toml_string_local(rest) {
if let Some(name) = cur_name.as_deref() {
if let Some(fix) = by_name.get(name) {
if current == fix.from_version {
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
out.push_str(indent);
out.push_str("version = \"");
out.push_str(&fix.to_version);
out.push_str("\"\n");
continue;
}
}
}
}
}
out.push_str(line);
out.push('\n');
}
if !content.ends_with('\n') {
out.pop();
}
out
}
fn extract_toml_string_local(rest: &str) -> Option<String> {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn rewrite_go_sum(content: &str, plan: &FixPlan) -> String {
let by_name: std::collections::HashMap<&str, &FixEntry> = plan
.entries
.iter()
.map(|e| (e.package.as_str(), e))
.collect();
let mut out = String::with_capacity(content.len() + 64);
for line in content.lines() {
let mut parts = line.split_ascii_whitespace();
let name = parts.next().unwrap_or("");
let ver = parts.next().unwrap_or("");
if let Some(fix) = by_name.get(name) {
let (base_ver, tail) = match ver.strip_suffix("/go.mod") {
Some(v) => (v, "/go.mod"),
None => (ver, ""),
};
if let Some(body) = base_ver.strip_prefix('v') {
if body == fix.from_version {
let rest_of_line = &line[name.len() + 1 + ver.len()..];
out.push_str(name);
out.push(' ');
out.push('v');
out.push_str(&fix.to_version);
out.push_str(tail);
out.push_str(rest_of_line);
out.push('\n');
continue;
}
}
}
out.push_str(line);
out.push('\n');
}
if !content.ends_with('\n') {
out.pop();
}
out
}
fn render_pr_bundle(plan: &FixPlan, pr_remote: Option<&str>) -> String {
let mut out = String::new();
out.push_str("Title: cleanlib fix — autonomous package upgrades\n");
if let Some(r) = pr_remote {
out.push_str(&format!("Remote: {}\n", r));
}
out.push_str("\nSummary:\n");
out.push_str(&format!(
"- {} upgrade(s) planned\n- {} skipped\n\n",
plan.entries.len(),
plan.skipped.len()
));
out.push_str("Upgrades:\n");
for e in &plan.entries {
out.push_str(&format!(
"- {} {}: {} → {} ({})\n",
e.ecosystem, e.package, e.from_version, e.to_version, e.reason
));
}
if !plan.skipped.is_empty() {
out.push_str("\nSkipped:\n");
for s in &plan.skipped {
let reason = match &s.reason {
SkipReason::RiskAccepted { rule } => format!("risk-accepted ({})", rule),
SkipReason::NotAllowListed => "not in --allow-list".to_string(),
SkipReason::Allowed => "verdict ALLOW".to_string(),
SkipReason::NoRecommendation => "no upgrade target".to_string(),
SkipReason::LookupError { error } => format!("lookup error: {}", error),
};
out.push_str(&format!("- {}@{} — {}\n", s.package, s.version, reason));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use cleanlib_client::types::Verdict;
fn npm_node(name: &str, version: &str) -> LockNode {
LockNode {
ecosystem: "npm".to_string(),
name: name.to_string(),
version: version.to_string(),
line: Some(10),
direct: true,
}
}
fn deny_verdict_with_recommendation(rec: &str) -> Verdict {
Verdict {
verdict_id: "01T".to_string(),
verdict: "VECTOR_VERDICT".to_string(),
source: "VECTOR_VERDICT".to_string(),
decision: Some("DENY".to_string()),
reasoning: "test".to_string(),
suggested_actions: vec![format!("recommended_version={}", rec)],
..Default::default()
}
}
fn allow_verdict() -> Verdict {
Verdict {
verdict_id: "01A".to_string(),
verdict: "ALLOWED_NO_FINDINGS".to_string(),
source: "ALLOWED_NO_FINDINGS".to_string(),
decision: Some("ALLOW".to_string()),
..Default::default()
}
}
#[test]
fn cleanlib_211_deny_with_recommendation_becomes_fix_entry() {
let verdicts = vec![(
npm_node("cors", "2.8.4"),
Ok(deny_verdict_with_recommendation("2.8.5")),
)];
let plan = plan_from_verdicts(
Path::new("/tmp/package-lock.json"),
Some(LockfileKind::PackageLockJson),
verdicts,
&None,
&[],
);
assert_eq!(plan.entries.len(), 1);
assert_eq!(plan.entries[0].from_version, "2.8.4");
assert_eq!(plan.entries[0].to_version, "2.8.5");
assert!(plan.skipped.is_empty());
}
#[test]
fn cleanlib_211_allow_is_skipped_not_upgraded() {
let verdicts = vec![(npm_node("lodash", "4.17.21"), Ok(allow_verdict()))];
let plan = plan_from_verdicts(
Path::new("/tmp/package-lock.json"),
Some(LockfileKind::PackageLockJson),
verdicts,
&None,
&[],
);
assert!(plan.entries.is_empty());
assert_eq!(plan.skipped.len(), 1);
assert!(matches!(plan.skipped[0].reason, SkipReason::Allowed));
}
#[test]
fn cleanlib_211_deny_without_recommendation_is_skipped() {
let mut v = deny_verdict_with_recommendation("");
v.suggested_actions.clear();
let verdicts = vec![(npm_node("evil-pkg", "1.0.0"), Ok(v))];
let plan = plan_from_verdicts(
Path::new("/tmp/package-lock.json"),
Some(LockfileKind::PackageLockJson),
verdicts,
&None,
&[],
);
assert!(plan.entries.is_empty());
assert_eq!(plan.skipped.len(), 1);
assert!(matches!(plan.skipped[0].reason, SkipReason::NoRecommendation));
}
#[test]
fn cleanlib_213_risk_accept_skips_matching_package() {
let verdicts = vec![(
npm_node("cors", "2.8.4"),
Ok(deny_verdict_with_recommendation("2.8.5")),
)];
let rule = RiskAcceptRule {
package: "cors".to_string(),
version_range: "<2.8.5".to_string(),
justification: "waiver".to_string(),
proposed_at: None,
expires_at: None,
};
let plan = plan_from_verdicts(
Path::new("/tmp/package-lock.json"),
Some(LockfileKind::PackageLockJson),
verdicts,
&None,
&[rule],
);
assert!(plan.entries.is_empty());
assert_eq!(plan.skipped.len(), 1);
match &plan.skipped[0].reason {
SkipReason::RiskAccepted { rule } => {
assert!(rule.contains("cors"));
}
other => panic!("expected RiskAccepted, got {:?}", other),
}
}
#[test]
fn cleanlib_214_allow_list_gates_which_packages_can_be_upgraded() {
let mut allow = HashSet::new();
allow.insert("cors".to_string());
let verdicts = vec![
(
npm_node("cors", "2.8.4"),
Ok(deny_verdict_with_recommendation("2.8.5")),
),
(
npm_node("lodash", "4.17.15"),
Ok(deny_verdict_with_recommendation("4.17.21")),
),
];
let plan = plan_from_verdicts(
Path::new("/tmp/package-lock.json"),
Some(LockfileKind::PackageLockJson),
verdicts,
&Some(allow),
&[],
);
assert_eq!(plan.entries.len(), 1);
assert_eq!(plan.entries[0].package, "cors");
assert_eq!(plan.skipped.len(), 1);
assert!(matches!(plan.skipped[0].reason, SkipReason::NotAllowListed));
}
#[test]
fn cleanlib_214_rewrite_package_lock_updates_versions() {
let content = r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/cors": { "version": "2.8.4" }
}
}"#;
let plan = FixPlan {
lockfile: PathBuf::from("package-lock.json"),
kind: Some(LockfileKind::PackageLockJson),
entries: vec![FixEntry {
ecosystem: "npm".to_string(),
package: "cors".to_string(),
from_version: "2.8.4".to_string(),
to_version: "2.8.5".to_string(),
reason: "test".to_string(),
line: Some(5),
direct: true,
}],
skipped: vec![],
};
let out = rewrite_lockfile(content, &plan).unwrap();
assert!(out.contains("\"2.8.5\""));
assert!(!out.contains("\"2.8.4\""));
}
#[test]
fn cleanlib_214_rewrite_cargo_lock_updates_matching_package() {
let content = r#"[[package]]
name = "my-app"
version = "0.1.0"
[[package]]
name = "serde"
version = "1.0.200"
source = "registry+x"
[[package]]
name = "tokio"
version = "1.30.0"
source = "registry+x"
"#;
let plan = FixPlan {
lockfile: PathBuf::from("Cargo.lock"),
kind: Some(LockfileKind::CargoLock),
entries: vec![FixEntry {
ecosystem: "crates".to_string(),
package: "serde".to_string(),
from_version: "1.0.200".to_string(),
to_version: "1.0.219".to_string(),
reason: "test".to_string(),
line: Some(7),
direct: false,
}],
skipped: vec![],
};
let out = rewrite_lockfile(content, &plan).unwrap();
assert!(out.contains("version = \"1.0.219\""));
assert!(!out.contains("version = \"1.0.200\""));
assert!(out.contains("version = \"1.30.0\""));
assert!(out.contains("version = \"0.1.0\""));
}
#[test]
fn extract_recommended_version_from_suggested_actions() {
let v = deny_verdict_with_recommendation("9.9.9");
assert_eq!(extract_recommended_version(&v).as_deref(), Some("9.9.9"));
let v = Verdict {
suggested_actions: vec!["recommended_version: 1.2.3".to_string()],
..Default::default()
};
assert_eq!(extract_recommended_version(&v).as_deref(), Some("1.2.3"));
let v = Verdict {
suggested_actions: vec!["do the thing".to_string()],
..Default::default()
};
assert_eq!(extract_recommended_version(&v), None);
}
#[test]
fn cleanlib_212_pr_bundle_lists_upgrades_and_skips() {
let plan = FixPlan {
lockfile: PathBuf::from("package-lock.json"),
kind: Some(LockfileKind::PackageLockJson),
entries: vec![FixEntry {
ecosystem: "npm".to_string(),
package: "cors".to_string(),
from_version: "2.8.4".to_string(),
to_version: "2.8.5".to_string(),
reason: "DENY: bad".to_string(),
line: Some(1),
direct: true,
}],
skipped: vec![SkippedEntry {
package: "lodash".to_string(),
version: "4.17.21".to_string(),
reason: SkipReason::Allowed,
}],
};
let bundle = render_pr_bundle(&plan, Some("git@github.com:acme/app.git"));
assert!(bundle.contains("cleanlib fix"));
assert!(bundle.contains("cors"));
assert!(bundle.contains("2.8.4 → 2.8.5"));
assert!(bundle.contains("lodash"));
assert!(bundle.contains("git@github.com:acme/app.git"));
}
}