use super::DryRun;
use crate::report::{Category, CheckReport};
use deps_core::EcosystemId;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EcosystemToken(pub EcosystemId);
impl Serialize for EcosystemToken {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.0.id())
}
}
impl<'de> Deserialize<'de> for EcosystemToken {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let token = String::deserialize(deserializer)?;
token
.parse::<EcosystemId>()
.map(EcosystemToken)
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeverityToken {
Error,
Warning,
Information,
Hint,
}
impl From<deps_core::diagnostic::Severity> for SeverityToken {
fn from(severity: deps_core::diagnostic::Severity) -> Self {
use deps_core::diagnostic::Severity;
match severity {
Severity::Error => Self::Error,
Severity::Warning => Self::Warning,
Severity::Information => Self::Information,
Severity::Hint => Self::Hint,
}
}
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct ReportDocument {
pub schema_version: u32,
pub findings: Vec<FindingDocument>,
#[serde(serialize_with = "serialize_summary_lexicographically")]
pub summary: BTreeMap<Category, usize>,
}
fn serialize_summary_lexicographically<S: Serializer>(
summary: &BTreeMap<Category, usize>,
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut entries: Vec<(&Category, &usize)> = summary.iter().collect();
entries.sort_by_key(|(category, _)| category.as_str());
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (category, count) in entries {
map.serialize_entry(category.as_str(), count)?;
}
map.end()
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct FindingDocument {
pub ecosystem: EcosystemToken,
pub manifest_path: String,
pub dependency_name: Option<String>,
pub requirement: Option<String>,
pub category: Category,
pub severity: SeverityToken,
pub range: RangeDocument,
pub message: String,
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct RangeDocument {
pub start: PositionDocument,
pub end: PositionDocument,
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct PositionDocument {
pub line: u32,
pub character: u32,
}
#[must_use]
pub fn to_document(report: &CheckReport) -> ReportDocument {
let findings = report
.findings
.iter()
.map(|finding| FindingDocument {
ecosystem: EcosystemToken(finding.ecosystem),
manifest_path: finding.manifest_path.display().to_string(),
dependency_name: finding.dependency_name.clone(),
requirement: finding.requirement.clone(),
category: finding.category,
severity: SeverityToken::from(finding.severity),
range: RangeDocument {
start: PositionDocument {
line: finding.range.start.line,
character: finding.range.start.character,
},
end: PositionDocument {
line: finding.range.end.line,
character: finding.range.end.character,
},
},
message: finding.message.clone(),
})
.collect();
ReportDocument {
schema_version: SCHEMA_VERSION,
findings,
summary: report.summary(),
}
}
pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(&to_document(report))
}
pub const UPDATE_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutcomeToken {
Applied,
Skipped,
RequiresLockfileUpdate,
Unfixable,
}
impl From<&crate::update::Outcome> for OutcomeToken {
fn from(outcome: &crate::update::Outcome) -> Self {
use crate::update::Outcome;
match outcome {
Outcome::Applied { .. } => Self::Applied,
Outcome::Skipped { .. } => Self::Skipped,
Outcome::RequiresLockfileUpdate { .. } => Self::RequiresLockfileUpdate,
Outcome::Unfixable(_) => Self::Unfixable,
}
}
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct UpdateReportDocument {
pub schema_version: u32,
pub dry_run: bool,
pub items: Vec<UpdateItemDocument>,
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
pub struct UpdateItemDocument {
pub name: String,
pub current: String,
pub target: Option<String>,
pub outcome: OutcomeToken,
pub reason: String,
pub advisory_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cooldown_fallback: Option<CooldownFallbackDocument>,
}
#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum CooldownFallbackDocument {
AppliedInsteadOf {
latest: String,
},
Blocked {
version: String,
},
}
#[must_use]
pub fn update_to_document(
plan: &crate::update::UpdatePlan,
dry_run: DryRun,
) -> UpdateReportDocument {
let items = plan
.items
.iter()
.map(|item| UpdateItemDocument {
name: crate::sanitize::sanitize_message_for_display(&item.name),
current: crate::sanitize::sanitize_message_for_display(&item.current.render_text()),
target: item
.target()
.map(|v| crate::sanitize::sanitize_message_for_display(v.as_str())),
outcome: OutcomeToken::from(&item.outcome),
reason: crate::sanitize::sanitize_message_for_display(&item.reason()),
advisory_ids: item.advisory_ids.clone(),
cooldown_fallback: item.cooldown_fallback.as_ref().map(|note| match note {
crate::update::CooldownFallbackNote::AppliedInsteadOf(latest) => {
CooldownFallbackDocument::AppliedInsteadOf {
latest: crate::sanitize::sanitize_message_for_display(latest.as_str()),
}
}
crate::update::CooldownFallbackNote::Blocked { version } => {
CooldownFallbackDocument::Blocked {
version: crate::sanitize::sanitize_message_for_display(version.as_str()),
}
}
}),
})
.collect();
UpdateReportDocument {
schema_version: UPDATE_SCHEMA_VERSION,
dry_run: dry_run == DryRun::Yes,
items,
}
}
pub fn render_update(
plan: &crate::update::UpdatePlan,
dry_run: DryRun,
) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(&update_to_document(plan, dry_run))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{Category, CheckFinding};
use deps_core::EcosystemId;
use deps_core::diagnostic::Severity;
use deps_core::position::{Position, Range};
use std::path::PathBuf;
fn finding() -> CheckFinding {
CheckFinding {
ecosystem: EcosystemId::Cargo,
manifest_path: PathBuf::from("Cargo.toml"),
dependency_name: Some("serde".to_string()),
requirement: Some("1.0".to_string()),
category: Category::Outdated,
code: None,
advisory_url: None,
advisory_severity: None,
severity: Severity::Hint,
range: Range::new(Position::new(4, 0), Position::new(4, 10)),
message: "Newer version available: 1.1.0".to_string(),
}
}
#[test]
fn test_to_document_empty_report() {
let document = to_document(&CheckReport::default());
assert_eq!(document.schema_version, SCHEMA_VERSION);
assert!(document.findings.is_empty());
assert!(document.summary.is_empty());
}
#[test]
fn test_to_document_maps_finding_fields() {
let document = to_document(&CheckReport {
findings: vec![finding()],
});
let f = &document.findings[0];
assert_eq!(f.ecosystem, EcosystemToken(EcosystemId::Cargo));
assert_eq!(f.manifest_path, "Cargo.toml");
assert_eq!(f.dependency_name.as_deref(), Some("serde"));
assert_eq!(f.requirement.as_deref(), Some("1.0"));
assert_eq!(f.category, Category::Outdated);
assert_eq!(f.severity, SeverityToken::Hint);
assert_eq!(f.range.start.line, 4);
assert_eq!(document.summary.get(&Category::Outdated), Some(&1));
}
#[test]
fn test_to_document_wire_tokens_are_byte_identical() {
let document = to_document(&CheckReport {
findings: vec![finding()],
});
let rendered = serde_json::to_string(&document).expect("must serialize");
assert!(rendered.contains("\"ecosystem\":\"cargo\""));
assert!(rendered.contains("\"category\":\"outdated\""));
assert!(rendered.contains("\"severity\":\"hint\""));
assert!(rendered.contains("\"outdated\":1"));
}
#[test]
fn test_summary_key_order_is_lexicographic_not_declaration_order() {
let mut deprecated = finding();
deprecated.category = Category::Deprecated;
let document = to_document(&CheckReport {
findings: vec![finding(), deprecated],
});
let rendered = serde_json::to_string(&document).expect("must serialize");
let summary_start = rendered
.find("\"summary\":")
.expect("summary field must be present");
let deprecated_index = rendered
.match_indices("\"deprecated\"")
.map(|(index, _)| index)
.find(|&index| index > summary_start)
.expect("deprecated key must appear in summary");
let outdated_index = rendered
.match_indices("\"outdated\"")
.map(|(index, _)| index)
.find(|&index| index > summary_start)
.expect("outdated key must appear in summary");
assert!(
deprecated_index < outdated_index,
"expected lexicographic order (deprecated before outdated), got: {rendered}"
);
}
#[test]
fn test_render_round_trips_through_serde_json() {
let report = CheckReport {
findings: vec![finding()],
};
let rendered = render(&report).expect("render must succeed");
let parsed: ReportDocument = serde_json::from_str(&rendered).expect("must round-trip");
assert_eq!(parsed, to_document(&report));
}
#[test]
fn test_render_includes_schema_version_field() {
let rendered = render(&CheckReport::default()).expect("render must succeed");
assert!(rendered.contains("\"schema_version\": 1"));
}
#[test]
fn test_to_document_snapshot() {
let mut other = finding();
other.category = Category::Vulnerable;
other.severity = Severity::Error;
other.manifest_path = PathBuf::from("package.json");
other.dependency_name = None;
other.requirement = None;
other.message = "GHSA-xxxx-yyyy-zzzz: example advisory".to_string();
let document = to_document(&CheckReport {
findings: vec![finding(), other],
});
insta::assert_json_snapshot!(document);
}
fn update_item(outcome: crate::update::Outcome) -> crate::update::PlannedUpdateItem {
crate::update::PlannedUpdateItem {
name: "serde".to_string(),
current: crate::update::CurrentVersion::Resolved(deps_core::ConcreteVersion::from(
"1.0.0",
)),
outcome,
advisory_ids: vec!["RUSTSEC-2024-0001".to_string()],
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}
}
fn applied_edit() -> deps_core::edit::ManifestEdit {
deps_core::edit::ManifestEdit {
range: Range::new(Position::new(0, 0), Position::new(0, 0)),
new_text: "1.2.0".to_string(),
}
}
fn applied_outcome() -> crate::update::Outcome {
crate::update::Outcome::Applied {
edit: applied_edit(),
target: deps_core::ConcreteVersion::from("1.2.0"),
}
}
#[test]
fn test_update_to_document_non_empty_plan_maps_every_field() {
let plan = crate::update::UpdatePlan {
items: vec![update_item(applied_outcome())],
};
let document = update_to_document(&plan, DryRun::Yes);
assert_eq!(document.schema_version, UPDATE_SCHEMA_VERSION);
assert!(document.dry_run);
assert_eq!(document.items.len(), 1);
let item = &document.items[0];
assert_eq!(item.name, "serde");
assert_eq!(item.current, "1.0.0");
assert_eq!(item.target.as_deref(), Some("1.2.0"));
assert_eq!(item.outcome, OutcomeToken::Applied);
assert_eq!(item.advisory_ids, vec!["RUSTSEC-2024-0001".to_string()]);
}
#[test]
fn test_render_update_non_empty_plan_round_trips_through_serde_json() {
let plan = crate::update::UpdatePlan {
items: vec![update_item(applied_outcome())],
};
let rendered = render_update(&plan, DryRun::No).expect("render must succeed");
let parsed: UpdateReportDocument =
serde_json::from_str(&rendered).expect("must round-trip");
assert_eq!(parsed, update_to_document(&plan, DryRun::No));
}
#[test]
fn test_update_to_document_none_target_renders_null() {
let item = update_item(crate::update::Outcome::Skipped {
reason: crate::update::SkipReason::NotRequested,
target: None,
});
let plan = crate::update::UpdatePlan { items: vec![item] };
let document = update_to_document(&plan, DryRun::No);
assert_eq!(document.items[0].target, None);
let rendered = render_update(&plan, DryRun::No).expect("render must succeed");
let value: serde_json::Value = serde_json::from_str(&rendered).expect("must parse");
let target = value["items"][0]
.as_object()
.expect("item must be an object")
.get("target")
.expect("target key must be present, not omitted");
assert!(
target.is_null(),
"target must render as null, got: {target:?}"
);
}
#[test]
fn test_update_to_document_strips_ansi_from_target_and_reason() {
let mut item = update_item(crate::update::Outcome::Skipped {
reason: crate::update::SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
),
target: Some(deps_core::ConcreteVersion::from("1.2.0\x1B[31m")),
});
item.cooldown_fallback = Some(crate::update::CooldownFallbackNote::Blocked {
version: deps_core::ConcreteVersion::from("1.1.0\x1B[31m"),
});
let mut applied_instead_of_item = update_item(applied_outcome());
applied_instead_of_item.cooldown_fallback =
Some(crate::update::CooldownFallbackNote::AppliedInsteadOf(
deps_core::ConcreteVersion::from("1.3.0\x1B[31m"),
));
let plan = crate::update::UpdatePlan {
items: vec![item, applied_instead_of_item],
};
let document = update_to_document(&plan, DryRun::No);
let doc_item = &document.items[0];
let target = doc_item.target.as_deref().expect("target must be Some");
assert!(!target.contains('\x1B'));
assert!(target.contains("1.2.0"));
assert!(!doc_item.reason.contains('\x1B'));
match &doc_item.cooldown_fallback {
Some(CooldownFallbackDocument::Blocked { version }) => {
assert!(!version.contains('\x1B'));
assert!(version.contains("1.1.0"));
}
other => panic!("expected Blocked, got: {other:?}"),
}
match &document.items[1].cooldown_fallback {
Some(CooldownFallbackDocument::AppliedInsteadOf { latest }) => {
assert!(!latest.contains('\x1B'));
assert!(latest.contains("1.3.0"));
}
other => panic!("expected AppliedInsteadOf, got: {other:?}"),
}
}
#[test]
fn test_outcome_token_matches_wire_token_for_every_variant() {
let cases: [(crate::update::Outcome, &str); 4] = [
(applied_outcome(), "applied"),
(
crate::update::Outcome::Skipped {
reason: crate::update::SkipReason::NotRequested,
target: None,
},
"skipped",
),
(
crate::update::Outcome::RequiresLockfileUpdate {
target: deps_core::ConcreteVersion::from("1.2.0"),
},
"requires-lockfile-update",
),
(
crate::update::Outcome::Unfixable(crate::update::UnfixableReason::NoVerifiedFix),
"unfixable",
),
];
for (outcome, wire_token) in &cases {
assert_eq!(outcome.wire_token(), *wire_token);
let token = OutcomeToken::from(outcome);
let json = serde_json::to_string(&token).expect("OutcomeToken must serialize");
assert_eq!(json, format!("\"{wire_token}\""));
let parsed: OutcomeToken = serde_json::from_str(&json).expect("must round-trip");
assert_eq!(parsed, token);
}
}
#[test]
fn test_outcome_token_rejects_unknown_string() {
let result: Result<OutcomeToken, _> = serde_json::from_str("\"not-a-real-outcome\"");
assert!(result.is_err());
}
#[test]
fn test_severity_token_matches_severity_str_for_every_variant() {
for severity in [
Severity::Error,
Severity::Warning,
Severity::Information,
Severity::Hint,
] {
let wire_token = crate::format::severity_str(severity);
let token = SeverityToken::from(severity);
let json = serde_json::to_string(&token).expect("SeverityToken must serialize");
assert_eq!(json, format!("\"{wire_token}\""));
let parsed: SeverityToken = serde_json::from_str(&json).expect("must round-trip");
assert_eq!(parsed, token);
}
}
#[test]
fn test_severity_token_rejects_unknown_string() {
let result: Result<SeverityToken, _> = serde_json::from_str("\"not-a-real-severity\"");
assert!(result.is_err());
}
#[test]
fn test_ecosystem_token_round_trips_every_ecosystem_id() {
for &ecosystem in EcosystemId::ALL {
let token = EcosystemToken(ecosystem);
let json = serde_json::to_string(&token).expect("EcosystemToken must serialize");
assert_eq!(json, format!("\"{}\"", ecosystem.id()));
let parsed: EcosystemToken = serde_json::from_str(&json).expect("must round-trip");
assert_eq!(parsed, token);
}
}
#[test]
fn test_ecosystem_token_rejects_unknown_string() {
let result: Result<EcosystemToken, _> = serde_json::from_str("\"not-a-real-ecosystem\"");
assert!(result.is_err());
}
}