Skip to main content

deps_cli/format/
json.rs

1//! Versioned JSON output (FR-007).
2//!
3//! Schema mirrors `specs/062-cli-check-mode/plan.md` §4 exactly. `schema_version` lets
4//! downstream tooling detect a future breaking change to this shape (constitution
5//! principle 8) — bump it, and document the bump in `CHANGELOG.md` as `Breaking`, whenever
6//! a field is renamed, removed, or its wire type/nullability changes (e.g. a sentinel value
7//! like `""` becoming `null`); adding a new optional field is not itself a bump.
8
9use super::DryRun;
10use crate::report::{Category, CheckReport};
11use deps_core::EcosystemId;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13use std::collections::BTreeMap;
14
15/// The current `schema_version` this module emits and [`ReportDocument`] can deserialize.
16pub const SCHEMA_VERSION: u32 = 1;
17
18/// Wire-token wrapper for [`EcosystemId`] in JSON DTOs.
19///
20/// Serializes to the same `id()` string the pre-#1626 `String` field carried, and
21/// deserializes through [`EcosystemId`]'s [`std::str::FromStr`] impl, so an unrecognized
22/// token is rejected instead of accepted as an opaque string.
23///
24/// # Examples
25///
26/// ```
27/// use deps_cli::format::json::EcosystemToken;
28/// use deps_core::EcosystemId;
29///
30/// let token = EcosystemToken(EcosystemId::Cargo);
31/// assert_eq!(serde_json::to_string(&token).unwrap(), "\"cargo\"");
32/// let parsed: EcosystemToken = serde_json::from_str("\"cargo\"").unwrap();
33/// assert_eq!(parsed.0, EcosystemId::Cargo);
34/// ```
35#[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/// Wire token for [`deps_core::diagnostic::Severity`] in JSON DTOs.
55///
56/// Byte-identical to [`crate::format::severity_str`]'s output. Kept as its own closed enum
57/// rather than reusing `Severity`'s own `Serialize` impl, which encodes the LSP protocol's
58/// `1..=4` integer form for an unrelated wire contract (`crate::policy_config`'s diagnostics
59/// config).
60///
61/// # Examples
62///
63/// ```
64/// use deps_cli::format::json::SeverityToken;
65/// use deps_core::diagnostic::Severity;
66///
67/// let token = SeverityToken::from(Severity::Warning);
68/// assert_eq!(serde_json::to_string(&token).unwrap(), "\"warning\"");
69/// ```
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum SeverityToken {
73    /// Reports an error.
74    Error,
75    /// Reports a warning.
76    Warning,
77    /// Reports information.
78    Information,
79    /// Reports a hint.
80    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/// Top-level JSON document shape.
96#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
97pub struct ReportDocument {
98    /// The schema version this document was produced under.
99    pub schema_version: u32,
100    /// Every finding, in the order [`CheckReport::findings`] held them.
101    pub findings: Vec<FindingDocument>,
102    /// Per-category finding counts (FR-009's category tokens as keys).
103    ///
104    /// Kept as `BTreeMap<Category, usize>` in memory ([`Category`]'s `Ord` is declaration
105    /// order, used elsewhere for `--fail-on`/table/SARIF ordering), but serialized through
106    /// `serialize_summary_lexicographically` (#1626 critic S1) so the JSON key order stays
107    /// the pre-#1626 `BTreeMap<String, usize>`'s lexicographic order rather than drifting to
108    /// `Category`'s declaration order.
109    #[serde(serialize_with = "serialize_summary_lexicographically")]
110    pub summary: BTreeMap<Category, usize>,
111}
112
113/// Serializes `summary` as a JSON object with keys in [`Category::as_str`] lexicographic
114/// order, matching the pre-#1626 `BTreeMap<String, usize>` wire order exactly.
115fn 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/// One finding's JSON shape.
132#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
133pub struct FindingDocument {
134    /// The ecosystem id (e.g. `"cargo"`).
135    pub ecosystem: EcosystemToken,
136    /// The manifest path, as reported by [`crate::walk`].
137    pub manifest_path: String,
138    /// The dependency name, when the finding could be traced to one manifest occurrence.
139    pub dependency_name: Option<String>,
140    /// The declared version requirement, when known.
141    pub requirement: Option<String>,
142    /// The FR-009 category token.
143    pub category: Category,
144    /// The lowercase severity token (`error`/`warning`/`information`/`hint`).
145    pub severity: SeverityToken,
146    /// The LSP range within the manifest.
147    pub range: RangeDocument,
148    /// The human-readable finding message.
149    pub message: String,
150}
151
152/// LSP `Range`'s JSON shape (`{"start": {...}, "end": {...}}`).
153#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
154pub struct RangeDocument {
155    /// The range's start position.
156    pub start: PositionDocument,
157    /// The range's end position.
158    pub end: PositionDocument,
159}
160
161/// LSP `Position`'s JSON shape (`{"line": ..., "character": ...}`), both zero-based.
162#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
163pub struct PositionDocument {
164    /// Zero-based line number.
165    pub line: u32,
166    /// Zero-based UTF-16 code-unit offset within the line.
167    pub character: u32,
168}
169
170/// Builds the versioned [`ReportDocument`] for `report`.
171///
172/// # Examples
173///
174/// ```
175/// use deps_cli::format::json::{SCHEMA_VERSION, to_document};
176/// use deps_cli::report::CheckReport;
177///
178/// let document = to_document(&CheckReport::default());
179/// assert_eq!(document.schema_version, SCHEMA_VERSION);
180/// assert!(document.findings.is_empty());
181/// ```
182#[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
215/// Renders `report` as a pretty-printed JSON string.
216///
217/// # Errors
218///
219/// Returns an error only if [`ReportDocument`]'s `Serialize` impl fails, which does not
220/// happen for the plain-data shape this module builds.
221pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
222    serde_json::to_string_pretty(&to_document(report))
223}
224
225/// The current `schema_version` [`update_to_document`] emits.
226pub const UPDATE_SCHEMA_VERSION: u32 = 2;
227
228/// Wire token for [`crate::update::Outcome`] in JSON DTOs.
229///
230/// Byte-identical to [`crate::update::Outcome::wire_token`]'s output. Kept as its own
231/// discriminant-only enum since `Outcome`'s variants carry data (`ManifestEdit`, `SkipReason`,
232/// `UnfixableReason`, ...) that must not leak into the DTO shape.
233///
234/// # Examples
235///
236/// ```
237/// use deps_cli::format::json::OutcomeToken;
238///
239/// let token = OutcomeToken::RequiresLockfileUpdate;
240/// assert_eq!(serde_json::to_string(&token).unwrap(), "\"requires-lockfile-update\"");
241/// ```
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "kebab-case")]
244pub enum OutcomeToken {
245    /// A fix plan existed and its edit was written (or would be, under `--dry-run`).
246    Applied,
247    /// Excluded from this run.
248    Skipped,
249    /// The already-declared requirement admits the fix target; no edit was needed.
250    RequiresLockfileUpdate,
251    /// No verified fix could be written.
252    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/// Top-level JSON document shape for an `update` run (FR-021).
268#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
269pub struct UpdateReportDocument {
270    /// The schema version this document was produced under.
271    pub schema_version: u32,
272    /// Whether `--dry-run` was passed — when `true`, every `"applied"` item's edit was
273    /// planned but **not** written to disk (critic finding M1/US-005: without this marker,
274    /// a `--dry-run` document is indistinguishable from a real run that wrote its edits).
275    pub dry_run: bool,
276    /// One entry per candidate dependency the planner considered.
277    pub items: Vec<UpdateItemDocument>,
278}
279
280/// One `update` plan item's JSON shape.
281#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
282pub struct UpdateItemDocument {
283    /// The dependency's declared (raw) name.
284    pub name: String,
285    /// The current (resolved in-use, or declared) version.
286    pub current: String,
287    /// The version this item's edit would move the dependency to, when applicable — `null`
288    /// when the item has no concrete target ([`crate::update::PlannedUpdateItem::target`]
289    /// returns `None`).
290    pub target: Option<String>,
291    /// One of `applied` / `skipped` / `requires-lockfile-update` / `unfixable`.
292    pub outcome: OutcomeToken,
293    /// A one-line human-readable reason for `outcome`.
294    pub reason: String,
295    /// OSV advisory ids this item resolves — non-empty only in `--security-only` mode.
296    pub advisory_ids: Vec<String>,
297    /// Spec 075 FR-013: this item's cooldown-fallback attribution, when one was consulted.
298    /// Additive (NFR-005) — omitted entirely, not `null`, when the item has none.
299    #[serde(skip_serializing_if = "Option::is_none", default)]
300    pub cooldown_fallback: Option<CooldownFallbackDocument>,
301}
302
303/// [`crate::update::CooldownFallbackNote`]'s JSON shape (spec 075 FR-013).
304#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
305#[serde(tag = "kind", rename_all = "kebab-case")]
306pub enum CooldownFallbackDocument {
307    /// The fallback candidate was written; `latest` names the excluded, cooldown-blocked
308    /// version this item targeted instead.
309    AppliedInsteadOf {
310        /// The excluded, cooldown-blocked `latest` version.
311        latest: String,
312    },
313    /// A fallback candidate existed but was itself OSV-`Flagged`/`Unverified` and was never
314    /// written; `version` names it.
315    Blocked {
316        /// The blocked fallback candidate's version.
317        version: String,
318    },
319}
320
321/// Builds the versioned [`UpdateReportDocument`] for `plan`.
322///
323/// # Examples
324///
325/// ```
326/// use deps_cli::format::DryRun;
327/// use deps_cli::format::json::{UPDATE_SCHEMA_VERSION, update_to_document};
328/// use deps_cli::update::UpdatePlan;
329///
330/// let document = update_to_document(&UpdatePlan::default(), DryRun::No);
331/// assert_eq!(document.schema_version, UPDATE_SCHEMA_VERSION);
332/// assert!(!document.dry_run);
333/// assert!(document.items.is_empty());
334/// ```
335#[must_use]
336pub fn update_to_document(
337    plan: &crate::update::UpdatePlan,
338    dry_run: DryRun,
339) -> UpdateReportDocument {
340    // Security-S3: `target`/`reason`/`cooldown_fallback` carry unvalidated `ConcreteVersion`
341    // text, sanitized here like `name`/`current` and `format::table::render_update`.
342    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
376/// Renders `plan` as a pretty-printed JSON string.
377///
378/// # Errors
379///
380/// Returns an error only if [`UpdateReportDocument`]'s `Serialize` impl fails, which does not
381/// happen for the plain-data shape this module builds.
382pub 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    /// #1626: the JSON wire tokens for `ecosystem`/`category`/`severity` must stay
439    /// byte-identical to the pre-retyping `String` fields — `EcosystemId::id()`,
440    /// `Category::as_str()`, `crate::format::severity_str()`.
441    #[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    /// #1626 critic S1: `summary`'s JSON key order must stay lexicographic on
454    /// [`Category::as_str`] — the pre-#1626 `BTreeMap<String, usize>` wire order — even though
455    /// `Category`'s `Ord` (used for `--fail-on`/table/SARIF ordering elsewhere) is declaration
456    /// order. `Outdated` sorts before `Deprecated` by declaration order but after it
457    /// lexicographically, so this mix catches a regression the all-`outdated`/`vulnerable`
458    /// snapshot test does not.
459    #[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    /// Snapshot test (S5, spec 062 review): pins the exact JSON document shape so a field
503    /// rename, key reordering, or nesting change shows up as a snapshot diff — this
504    /// document is a stable, versioned public schema (`SCHEMA_VERSION`), not an
505    /// implementation detail.
506    #[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    /// S5: `update_to_document` on a non-empty plan — every field (including the `dry_run`
551    /// marker and advisory ids) must survive into the document, not just the empty-plan
552    /// doctest's shape.
553    #[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    /// #1629: `None` (empty/no target) maps to `null` on the wire (`UPDATE_SCHEMA_VERSION`
582    /// bumped to 2), replacing the pre-#1629 `""`-sentinel convention. Asserts the rendered
583    /// JSON text itself, not just the struct-level `Option`, so a future accidental
584    /// `skip_serializing_if` regression (key *absent* instead of present-and-`null`) is caught.
585    #[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    /// #1605 critic S1: `target` and `reason`/`cooldown_fallback` both carry unvalidated
609    /// registry text (`ConcreteVersion` is deliberately unchecked) and must both be sanitized.
610    #[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    /// #1626: `OutcomeToken`'s wire tokens must stay byte-identical to the pre-retyping
652    /// `Outcome::wire_token()` strings for every variant.
653    #[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            // #1626 tester gap 3: `OutcomeToken` only got direct `Deserialize` coverage for
681            // `Applied` (indirectly, via the update-plan round-trip test) — assert all four
682            // deserialize back to the exact token that produced their JSON.
683            let parsed: OutcomeToken = serde_json::from_str(&json).expect("must round-trip");
684            assert_eq!(parsed, token);
685        }
686    }
687
688    /// #1626 tester gap 1: an unrecognized `outcome` token must be a hard deserialize error,
689    /// not silently accepted or defaulted.
690    #[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    /// #1626 critic M1: `SeverityToken`'s wire tokens must stay byte-identical to
697    /// `crate::format::severity_str`'s strings for every variant — the doctest and the
698    /// `test_to_document_maps_finding_fields`/`test_to_document_wire_tokens_are_byte_identical`
699    /// tests above only ever exercised `Hint`/`Warning`; this covers all four.
700    #[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    /// #1626 tester gap 1: an unrecognized `severity` token must be a hard deserialize error.
718    #[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    /// #1626 tester gap 4: `EcosystemToken` round-trips every [`EcosystemId`] variant, not
725    /// just `Cargo` — table-driven over [`EcosystemId::ALL`] since `id()`/`FromStr` are
726    /// macro-generated from the same variant list, so per-variant divergence is structurally
727    /// unlikely but still worth a cheap blanket check.
728    #[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    /// #1626 tester gap 1: an unrecognized `ecosystem` token must be a hard deserialize error.
740    #[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}