1use super::DryRun;
10use crate::report::{Category, CheckReport};
11use deps_core::EcosystemId;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13use std::collections::BTreeMap;
14
15pub const SCHEMA_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct EcosystemToken(pub EcosystemId);
37
38impl Serialize for EcosystemToken {
39 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
40 serializer.serialize_str(self.0.id())
41 }
42}
43
44impl<'de> Deserialize<'de> for EcosystemToken {
45 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
46 let token = String::deserialize(deserializer)?;
47 token
48 .parse::<EcosystemId>()
49 .map(EcosystemToken)
50 .map_err(serde::de::Error::custom)
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum SeverityToken {
73 Error,
75 Warning,
77 Information,
79 Hint,
81}
82
83impl From<deps_core::diagnostic::Severity> for SeverityToken {
84 fn from(severity: deps_core::diagnostic::Severity) -> Self {
85 use deps_core::diagnostic::Severity;
86 match severity {
87 Severity::Error => Self::Error,
88 Severity::Warning => Self::Warning,
89 Severity::Information => Self::Information,
90 Severity::Hint => Self::Hint,
91 }
92 }
93}
94
95#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
97pub struct ReportDocument {
98 pub schema_version: u32,
100 pub findings: Vec<FindingDocument>,
102 #[serde(serialize_with = "serialize_summary_lexicographically")]
110 pub summary: BTreeMap<Category, usize>,
111}
112
113fn serialize_summary_lexicographically<S: Serializer>(
116 summary: &BTreeMap<Category, usize>,
117 serializer: S,
118) -> Result<S::Ok, S::Error> {
119 use serde::ser::SerializeMap;
120
121 let mut entries: Vec<(&Category, &usize)> = summary.iter().collect();
122 entries.sort_by_key(|(category, _)| category.as_str());
123
124 let mut map = serializer.serialize_map(Some(entries.len()))?;
125 for (category, count) in entries {
126 map.serialize_entry(category.as_str(), count)?;
127 }
128 map.end()
129}
130
131#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
133pub struct FindingDocument {
134 pub ecosystem: EcosystemToken,
136 pub manifest_path: String,
138 pub dependency_name: Option<String>,
140 pub requirement: Option<String>,
142 pub category: Category,
144 pub severity: SeverityToken,
146 pub range: RangeDocument,
148 pub message: String,
150}
151
152#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
154pub struct RangeDocument {
155 pub start: PositionDocument,
157 pub end: PositionDocument,
159}
160
161#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
163pub struct PositionDocument {
164 pub line: u32,
166 pub character: u32,
168}
169
170#[must_use]
183pub fn to_document(report: &CheckReport) -> ReportDocument {
184 let findings = report
185 .findings
186 .iter()
187 .map(|finding| FindingDocument {
188 ecosystem: EcosystemToken(finding.ecosystem),
189 manifest_path: finding.manifest_path.display().to_string(),
190 dependency_name: finding.dependency_name.clone(),
191 requirement: finding.requirement.clone(),
192 category: finding.category,
193 severity: SeverityToken::from(finding.severity),
194 range: RangeDocument {
195 start: PositionDocument {
196 line: finding.range.start.line,
197 character: finding.range.start.character,
198 },
199 end: PositionDocument {
200 line: finding.range.end.line,
201 character: finding.range.end.character,
202 },
203 },
204 message: finding.message.clone(),
205 })
206 .collect();
207
208 ReportDocument {
209 schema_version: SCHEMA_VERSION,
210 findings,
211 summary: report.summary(),
212 }
213}
214
215pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
222 serde_json::to_string_pretty(&to_document(report))
223}
224
225pub const UPDATE_SCHEMA_VERSION: u32 = 2;
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "kebab-case")]
244pub enum OutcomeToken {
245 Applied,
247 Skipped,
249 RequiresLockfileUpdate,
251 Unfixable,
253}
254
255impl From<&crate::update::Outcome> for OutcomeToken {
256 fn from(outcome: &crate::update::Outcome) -> Self {
257 use crate::update::Outcome;
258 match outcome {
259 Outcome::Applied { .. } => Self::Applied,
260 Outcome::Skipped { .. } => Self::Skipped,
261 Outcome::RequiresLockfileUpdate { .. } => Self::RequiresLockfileUpdate,
262 Outcome::Unfixable(_) => Self::Unfixable,
263 }
264 }
265}
266
267#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
269pub struct UpdateReportDocument {
270 pub schema_version: u32,
272 pub dry_run: bool,
276 pub items: Vec<UpdateItemDocument>,
278}
279
280#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
282pub struct UpdateItemDocument {
283 pub name: String,
285 pub current: String,
287 pub target: Option<String>,
291 pub outcome: OutcomeToken,
293 pub reason: String,
295 pub advisory_ids: Vec<String>,
297 #[serde(skip_serializing_if = "Option::is_none", default)]
300 pub cooldown_fallback: Option<CooldownFallbackDocument>,
301}
302
303#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
305#[serde(tag = "kind", rename_all = "kebab-case")]
306pub enum CooldownFallbackDocument {
307 AppliedInsteadOf {
310 latest: String,
312 },
313 Blocked {
316 version: String,
318 },
319}
320
321#[must_use]
336pub fn update_to_document(
337 plan: &crate::update::UpdatePlan,
338 dry_run: DryRun,
339) -> UpdateReportDocument {
340 let items = plan
343 .items
344 .iter()
345 .map(|item| UpdateItemDocument {
346 name: crate::sanitize::sanitize_message_for_display(&item.name),
347 current: crate::sanitize::sanitize_message_for_display(&item.current.render_text()),
348 target: item
349 .target()
350 .map(|v| crate::sanitize::sanitize_message_for_display(v.as_str())),
351 outcome: OutcomeToken::from(&item.outcome),
352 reason: crate::sanitize::sanitize_message_for_display(&item.reason()),
353 advisory_ids: item.advisory_ids.clone(),
354 cooldown_fallback: item.cooldown_fallback.as_ref().map(|note| match note {
355 crate::update::CooldownFallbackNote::AppliedInsteadOf(latest) => {
356 CooldownFallbackDocument::AppliedInsteadOf {
357 latest: crate::sanitize::sanitize_message_for_display(latest.as_str()),
358 }
359 }
360 crate::update::CooldownFallbackNote::Blocked { version } => {
361 CooldownFallbackDocument::Blocked {
362 version: crate::sanitize::sanitize_message_for_display(version.as_str()),
363 }
364 }
365 }),
366 })
367 .collect();
368
369 UpdateReportDocument {
370 schema_version: UPDATE_SCHEMA_VERSION,
371 dry_run: dry_run == DryRun::Yes,
372 items,
373 }
374}
375
376pub fn render_update(
383 plan: &crate::update::UpdatePlan,
384 dry_run: DryRun,
385) -> Result<String, serde_json::Error> {
386 serde_json::to_string_pretty(&update_to_document(plan, dry_run))
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::report::{Category, CheckFinding};
393 use deps_core::EcosystemId;
394 use deps_core::diagnostic::Severity;
395 use deps_core::position::{Position, Range};
396 use std::path::PathBuf;
397
398 fn finding() -> CheckFinding {
399 CheckFinding {
400 ecosystem: EcosystemId::Cargo,
401 manifest_path: PathBuf::from("Cargo.toml"),
402 dependency_name: Some("serde".to_string()),
403 requirement: Some("1.0".to_string()),
404 category: Category::Outdated,
405 code: None,
406 advisory_url: None,
407 advisory_severity: None,
408 severity: Severity::Hint,
409 range: Range::new(Position::new(4, 0), Position::new(4, 10)),
410 message: "Newer version available: 1.1.0".to_string(),
411 }
412 }
413
414 #[test]
415 fn test_to_document_empty_report() {
416 let document = to_document(&CheckReport::default());
417 assert_eq!(document.schema_version, SCHEMA_VERSION);
418 assert!(document.findings.is_empty());
419 assert!(document.summary.is_empty());
420 }
421
422 #[test]
423 fn test_to_document_maps_finding_fields() {
424 let document = to_document(&CheckReport {
425 findings: vec![finding()],
426 });
427 let f = &document.findings[0];
428 assert_eq!(f.ecosystem, EcosystemToken(EcosystemId::Cargo));
429 assert_eq!(f.manifest_path, "Cargo.toml");
430 assert_eq!(f.dependency_name.as_deref(), Some("serde"));
431 assert_eq!(f.requirement.as_deref(), Some("1.0"));
432 assert_eq!(f.category, Category::Outdated);
433 assert_eq!(f.severity, SeverityToken::Hint);
434 assert_eq!(f.range.start.line, 4);
435 assert_eq!(document.summary.get(&Category::Outdated), Some(&1));
436 }
437
438 #[test]
442 fn test_to_document_wire_tokens_are_byte_identical() {
443 let document = to_document(&CheckReport {
444 findings: vec![finding()],
445 });
446 let rendered = serde_json::to_string(&document).expect("must serialize");
447 assert!(rendered.contains("\"ecosystem\":\"cargo\""));
448 assert!(rendered.contains("\"category\":\"outdated\""));
449 assert!(rendered.contains("\"severity\":\"hint\""));
450 assert!(rendered.contains("\"outdated\":1"));
451 }
452
453 #[test]
460 fn test_summary_key_order_is_lexicographic_not_declaration_order() {
461 let mut deprecated = finding();
462 deprecated.category = Category::Deprecated;
463 let document = to_document(&CheckReport {
464 findings: vec![finding(), deprecated],
465 });
466 let rendered = serde_json::to_string(&document).expect("must serialize");
467 let summary_start = rendered
468 .find("\"summary\":")
469 .expect("summary field must be present");
470 let deprecated_index = rendered
471 .match_indices("\"deprecated\"")
472 .map(|(index, _)| index)
473 .find(|&index| index > summary_start)
474 .expect("deprecated key must appear in summary");
475 let outdated_index = rendered
476 .match_indices("\"outdated\"")
477 .map(|(index, _)| index)
478 .find(|&index| index > summary_start)
479 .expect("outdated key must appear in summary");
480 assert!(
481 deprecated_index < outdated_index,
482 "expected lexicographic order (deprecated before outdated), got: {rendered}"
483 );
484 }
485
486 #[test]
487 fn test_render_round_trips_through_serde_json() {
488 let report = CheckReport {
489 findings: vec![finding()],
490 };
491 let rendered = render(&report).expect("render must succeed");
492 let parsed: ReportDocument = serde_json::from_str(&rendered).expect("must round-trip");
493 assert_eq!(parsed, to_document(&report));
494 }
495
496 #[test]
497 fn test_render_includes_schema_version_field() {
498 let rendered = render(&CheckReport::default()).expect("render must succeed");
499 assert!(rendered.contains("\"schema_version\": 1"));
500 }
501
502 #[test]
507 fn test_to_document_snapshot() {
508 let mut other = finding();
509 other.category = Category::Vulnerable;
510 other.severity = Severity::Error;
511 other.manifest_path = PathBuf::from("package.json");
512 other.dependency_name = None;
513 other.requirement = None;
514 other.message = "GHSA-xxxx-yyyy-zzzz: example advisory".to_string();
515
516 let document = to_document(&CheckReport {
517 findings: vec![finding(), other],
518 });
519 insta::assert_json_snapshot!(document);
520 }
521
522 fn update_item(outcome: crate::update::Outcome) -> crate::update::PlannedUpdateItem {
523 crate::update::PlannedUpdateItem {
524 name: "serde".to_string(),
525 current: crate::update::CurrentVersion::Resolved(deps_core::ConcreteVersion::from(
526 "1.0.0",
527 )),
528 outcome,
529 advisory_ids: vec!["RUSTSEC-2024-0001".to_string()],
530 ignore_rule_overridden: false,
531 gossip_excluded_version: None,
532 cooldown_fallback: None,
533 }
534 }
535
536 fn applied_edit() -> deps_core::edit::ManifestEdit {
537 deps_core::edit::ManifestEdit {
538 range: Range::new(Position::new(0, 0), Position::new(0, 0)),
539 new_text: "1.2.0".to_string(),
540 }
541 }
542
543 fn applied_outcome() -> crate::update::Outcome {
544 crate::update::Outcome::Applied {
545 edit: applied_edit(),
546 target: deps_core::ConcreteVersion::from("1.2.0"),
547 }
548 }
549
550 #[test]
554 fn test_update_to_document_non_empty_plan_maps_every_field() {
555 let plan = crate::update::UpdatePlan {
556 items: vec![update_item(applied_outcome())],
557 };
558 let document = update_to_document(&plan, DryRun::Yes);
559 assert_eq!(document.schema_version, UPDATE_SCHEMA_VERSION);
560 assert!(document.dry_run);
561 assert_eq!(document.items.len(), 1);
562 let item = &document.items[0];
563 assert_eq!(item.name, "serde");
564 assert_eq!(item.current, "1.0.0");
565 assert_eq!(item.target.as_deref(), Some("1.2.0"));
566 assert_eq!(item.outcome, OutcomeToken::Applied);
567 assert_eq!(item.advisory_ids, vec!["RUSTSEC-2024-0001".to_string()]);
568 }
569
570 #[test]
571 fn test_render_update_non_empty_plan_round_trips_through_serde_json() {
572 let plan = crate::update::UpdatePlan {
573 items: vec![update_item(applied_outcome())],
574 };
575 let rendered = render_update(&plan, DryRun::No).expect("render must succeed");
576 let parsed: UpdateReportDocument =
577 serde_json::from_str(&rendered).expect("must round-trip");
578 assert_eq!(parsed, update_to_document(&plan, DryRun::No));
579 }
580
581 #[test]
586 fn test_update_to_document_none_target_renders_null() {
587 let item = update_item(crate::update::Outcome::Skipped {
588 reason: crate::update::SkipReason::NotRequested,
589 target: None,
590 });
591 let plan = crate::update::UpdatePlan { items: vec![item] };
592 let document = update_to_document(&plan, DryRun::No);
593 assert_eq!(document.items[0].target, None);
594
595 let rendered = render_update(&plan, DryRun::No).expect("render must succeed");
596 let value: serde_json::Value = serde_json::from_str(&rendered).expect("must parse");
597 let target = value["items"][0]
598 .as_object()
599 .expect("item must be an object")
600 .get("target")
601 .expect("target key must be present, not omitted");
602 assert!(
603 target.is_null(),
604 "target must render as null, got: {target:?}"
605 );
606 }
607
608 #[test]
611 fn test_update_to_document_strips_ansi_from_target_and_reason() {
612 let mut item = update_item(crate::update::Outcome::Skipped {
613 reason: crate::update::SkipReason::NotSafelyEditable(
614 deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
615 ),
616 target: Some(deps_core::ConcreteVersion::from("1.2.0\x1B[31m")),
617 });
618 item.cooldown_fallback = Some(crate::update::CooldownFallbackNote::Blocked {
619 version: deps_core::ConcreteVersion::from("1.1.0\x1B[31m"),
620 });
621 let mut applied_instead_of_item = update_item(applied_outcome());
622 applied_instead_of_item.cooldown_fallback =
623 Some(crate::update::CooldownFallbackNote::AppliedInsteadOf(
624 deps_core::ConcreteVersion::from("1.3.0\x1B[31m"),
625 ));
626 let plan = crate::update::UpdatePlan {
627 items: vec![item, applied_instead_of_item],
628 };
629 let document = update_to_document(&plan, DryRun::No);
630 let doc_item = &document.items[0];
631 let target = doc_item.target.as_deref().expect("target must be Some");
632 assert!(!target.contains('\x1B'));
633 assert!(target.contains("1.2.0"));
634 assert!(!doc_item.reason.contains('\x1B'));
635 match &doc_item.cooldown_fallback {
636 Some(CooldownFallbackDocument::Blocked { version }) => {
637 assert!(!version.contains('\x1B'));
638 assert!(version.contains("1.1.0"));
639 }
640 other => panic!("expected Blocked, got: {other:?}"),
641 }
642 match &document.items[1].cooldown_fallback {
643 Some(CooldownFallbackDocument::AppliedInsteadOf { latest }) => {
644 assert!(!latest.contains('\x1B'));
645 assert!(latest.contains("1.3.0"));
646 }
647 other => panic!("expected AppliedInsteadOf, got: {other:?}"),
648 }
649 }
650
651 #[test]
654 fn test_outcome_token_matches_wire_token_for_every_variant() {
655 let cases: [(crate::update::Outcome, &str); 4] = [
656 (applied_outcome(), "applied"),
657 (
658 crate::update::Outcome::Skipped {
659 reason: crate::update::SkipReason::NotRequested,
660 target: None,
661 },
662 "skipped",
663 ),
664 (
665 crate::update::Outcome::RequiresLockfileUpdate {
666 target: deps_core::ConcreteVersion::from("1.2.0"),
667 },
668 "requires-lockfile-update",
669 ),
670 (
671 crate::update::Outcome::Unfixable(crate::update::UnfixableReason::NoVerifiedFix),
672 "unfixable",
673 ),
674 ];
675 for (outcome, wire_token) in &cases {
676 assert_eq!(outcome.wire_token(), *wire_token);
677 let token = OutcomeToken::from(outcome);
678 let json = serde_json::to_string(&token).expect("OutcomeToken must serialize");
679 assert_eq!(json, format!("\"{wire_token}\""));
680 let parsed: OutcomeToken = serde_json::from_str(&json).expect("must round-trip");
684 assert_eq!(parsed, token);
685 }
686 }
687
688 #[test]
691 fn test_outcome_token_rejects_unknown_string() {
692 let result: Result<OutcomeToken, _> = serde_json::from_str("\"not-a-real-outcome\"");
693 assert!(result.is_err());
694 }
695
696 #[test]
701 fn test_severity_token_matches_severity_str_for_every_variant() {
702 for severity in [
703 Severity::Error,
704 Severity::Warning,
705 Severity::Information,
706 Severity::Hint,
707 ] {
708 let wire_token = crate::format::severity_str(severity);
709 let token = SeverityToken::from(severity);
710 let json = serde_json::to_string(&token).expect("SeverityToken must serialize");
711 assert_eq!(json, format!("\"{wire_token}\""));
712 let parsed: SeverityToken = serde_json::from_str(&json).expect("must round-trip");
713 assert_eq!(parsed, token);
714 }
715 }
716
717 #[test]
719 fn test_severity_token_rejects_unknown_string() {
720 let result: Result<SeverityToken, _> = serde_json::from_str("\"not-a-real-severity\"");
721 assert!(result.is_err());
722 }
723
724 #[test]
729 fn test_ecosystem_token_round_trips_every_ecosystem_id() {
730 for &ecosystem in EcosystemId::ALL {
731 let token = EcosystemToken(ecosystem);
732 let json = serde_json::to_string(&token).expect("EcosystemToken must serialize");
733 assert_eq!(json, format!("\"{}\"", ecosystem.id()));
734 let parsed: EcosystemToken = serde_json::from_str(&json).expect("must round-trip");
735 assert_eq!(parsed, token);
736 }
737 }
738
739 #[test]
741 fn test_ecosystem_token_rejects_unknown_string() {
742 let result: Result<EcosystemToken, _> = serde_json::from_str("\"not-a-real-ecosystem\"");
743 assert!(result.is_err());
744 }
745}