Skip to main content

car/
diagnostics.rs

1//! Archive diagnostics for unsupported, unknown, and unresolved data.
2
3use crate::asset::{AssetKind, asset_kind_for_item};
4use crate::car::{CSIItem, Car, ReferenceResolveError};
5use crate::metadata::{TlvMetadata, UnknownTlv};
6use crate::model::rendition::{CompressionType, LayoutType, Rendition, RenditionKind};
7use crate::model::{ColorModel, Encoding};
8use crate::output;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct DiagnosticsReport {
12    pub totals: DiagnosticsTotals,
13    pub entries: Vec<EntryDiagnostics>,
14}
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct DiagnosticsTotals {
18    pub facets: usize,
19    pub entries: usize,
20    pub supported_outputs: usize,
21    pub unsupported_outputs: usize,
22    pub internal_references: usize,
23    pub unresolved_references: usize,
24    pub unknown_renditions: usize,
25    pub unknown_tlvs: usize,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct EntryDiagnostics {
30    pub facet_name: String,
31    pub rendition_name: String,
32    pub key_values: Vec<u16>,
33    pub width: u32,
34    pub height: u32,
35    pub scale: u32,
36    pub layout: LayoutType,
37    pub encoding: Encoding,
38    pub color_model: ColorModel,
39    pub rendition_kind: RenditionKind,
40    pub compression: Option<CompressionType>,
41    pub asset_kind: AssetKind,
42    pub tlv: TlvMetadata,
43    pub issues: Vec<DiagnosticIssue>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum DiagnosticIssue {
48    Unsupported(UnsupportedReason),
49    UnknownRendition { tag: [u8; 4], byte_len: usize },
50    UnknownTlv(UnknownTlv),
51    UnresolvedReference(ReferenceResolveError),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum UnsupportedReason {
56    Layout(LayoutType),
57    RenditionKind(RenditionKind),
58    Encoding(Encoding),
59    Compression(CompressionType),
60}
61
62impl Car {
63    pub fn diagnostics(&self) -> DiagnosticsReport {
64        let mut totals = DiagnosticsTotals::default();
65        let mut entries = Vec::new();
66
67        for (facet_name, facet) in self.named_facets() {
68            totals.facets += 1;
69            let Some(items) = self.items_for_facet(facet) else {
70                continue;
71            };
72            for item in items {
73                totals.entries += 1;
74                let diagnostics = self.entry_diagnostics(facet_name, item);
75                if diagnostics
76                    .issues
77                    .iter()
78                    .any(|issue| matches!(issue, DiagnosticIssue::Unsupported(_)))
79                {
80                    totals.unsupported_outputs += 1;
81                } else {
82                    totals.supported_outputs += 1;
83                }
84                if matches!(item.layout(), LayoutType::InternalReference) {
85                    totals.internal_references += 1;
86                }
87                for issue in &diagnostics.issues {
88                    match issue {
89                        DiagnosticIssue::UnknownRendition { .. } => totals.unknown_renditions += 1,
90                        DiagnosticIssue::UnknownTlv(_) => totals.unknown_tlvs += 1,
91                        DiagnosticIssue::UnresolvedReference(_) => {
92                            totals.unresolved_references += 1
93                        }
94                        DiagnosticIssue::Unsupported(_) => {}
95                    }
96                }
97                entries.push(diagnostics);
98            }
99        }
100
101        DiagnosticsReport { totals, entries }
102    }
103
104    fn entry_diagnostics(&self, facet_name: &str, item: &CSIItem) -> EntryDiagnostics {
105        let tlv = item.tlv_metadata();
106        let mut issues = Vec::new();
107
108        if let Some(reason) = unsupported_reason(item) {
109            issues.push(DiagnosticIssue::Unsupported(reason));
110        }
111
112        if let Some(Rendition::Unknown { tag, data }) = item.header().rendition.as_ref() {
113            issues.push(DiagnosticIssue::UnknownRendition {
114                tag: *tag,
115                byte_len: data.raw_data.len(),
116            });
117        }
118
119        issues.extend(tlv.unknown.iter().cloned().map(DiagnosticIssue::UnknownTlv));
120
121        if matches!(item.layout(), LayoutType::InternalReference)
122            && let Err(error) = self.try_resolve_internal_reference(item)
123        {
124            issues.push(DiagnosticIssue::UnresolvedReference(error));
125        }
126
127        EntryDiagnostics {
128            facet_name: facet_name.to_string(),
129            rendition_name: item.name().to_string(),
130            key_values: item.key_values().to_vec(),
131            width: item.width(),
132            height: item.height(),
133            scale: item.scale(),
134            layout: item.layout(),
135            encoding: item.encoding(),
136            color_model: item.color_model(),
137            rendition_kind: item.rendition_kind(),
138            compression: item.compression(),
139            asset_kind: asset_kind_for_item(item),
140            tlv,
141            issues,
142        }
143    }
144}
145
146impl CSIItem {
147    pub fn unsupported_reason(&self) -> Option<UnsupportedReason> {
148        unsupported_reason(self)
149    }
150}
151
152fn unsupported_reason(item: &CSIItem) -> Option<UnsupportedReason> {
153    if matches!(item.layout(), LayoutType::InternalReference) {
154        return None;
155    }
156
157    match item.header().rendition.as_ref() {
158        Some(Rendition::Unknown { .. }) => {
159            Some(UnsupportedReason::RenditionKind(RenditionKind::Unknown))
160        }
161        None => Some(UnsupportedReason::RenditionKind(RenditionKind::None)),
162        // These renditions export as JSON and do not require a pixel encoding.
163        Some(Rendition::Color(_) | Rendition::MultisizeImageSet(_)) => None,
164        Some(Rendition::ThemeCBCK(cbck)) if unsupported_compression(cbck.compression_type) => {
165            Some(UnsupportedReason::Compression(cbck.compression_type))
166        }
167        Some(_) if matches!(item.encoding(), Encoding::Unknown { .. } | Encoding::None) => {
168            Some(UnsupportedReason::Encoding(item.encoding()))
169        }
170        Some(_) if output::supported_output_identity(item).is_none() => {
171            Some(UnsupportedReason::Layout(item.layout()))
172        }
173        _ => None,
174    }
175}
176
177fn unsupported_compression(compression: CompressionType) -> bool {
178    !matches!(
179        compression,
180        CompressionType::Uncompressed
181            | CompressionType::Zip
182            | CompressionType::Lzfse
183            | CompressionType::Lzvn
184            | CompressionType::HEVC
185            | CompressionType::Deepmap2
186    )
187}
188
189#[cfg(test)]
190mod tests {
191    use std::path::PathBuf;
192
193    use super::*;
194
195    fn fixture_path(name: &str) -> PathBuf {
196        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
197            .join("../car-tests/data")
198            .join(name)
199    }
200
201    #[test]
202    fn metadata_without_pixel_encoding_are_supported_as_json() {
203        let car = Car::new(fixture_path("Assets.car")).expect("load smoke fixture");
204        let plan = crate::export::plan_export(&car, "out");
205        let report = car.diagnostics();
206
207        for (facet_name, kind) in [
208            ("ActionSheet_Action_Icon_Color", RenditionKind::Color),
209            ("AppIcon", RenditionKind::MultisizeImageSet),
210        ] {
211            let jobs = plan
212                .jobs
213                .iter()
214                .filter(|job| {
215                    job.logical_facet_name == facet_name
216                        && job.payload_item.rendition_kind() == kind
217                })
218                .collect::<Vec<_>>();
219
220            assert_eq!(jobs.len(), 2, "{facet_name} metadata outputs");
221            for job in jobs {
222                assert_eq!(job.format, crate::export::ExportFormat::Json);
223                assert_eq!(job.payload_item.encoding(), Encoding::None);
224                assert_eq!(job.payload_item.unsupported_reason(), None, "{facet_name}");
225            }
226
227            let entries = report
228                .entries
229                .iter()
230                .filter(|entry| entry.facet_name == facet_name && entry.rendition_kind == kind)
231                .collect::<Vec<_>>();
232            assert_eq!(entries.len(), 2, "{facet_name} metadata diagnostics");
233            assert!(entries.iter().all(|entry| {
234                !entry
235                    .issues
236                    .iter()
237                    .any(|issue| matches!(issue, DiagnosticIssue::Unsupported(_)))
238            }));
239        }
240    }
241
242    #[test]
243    fn metadata_outputs_do_not_inflate_unsupported_totals() {
244        let car = Car::new(fixture_path("Assets.car")).expect("load smoke fixture");
245        let report = car.diagnostics();
246        for kind in [AssetKind::Color, AssetKind::MultisizeImageSet] {
247            assert!(report.entries.iter().any(|entry| entry.asset_kind == kind));
248        }
249        let unsupported_non_metadata = report
250            .entries
251            .iter()
252            .filter(|entry| {
253                !matches!(
254                    entry.asset_kind,
255                    AssetKind::Color | AssetKind::MultisizeImageSet
256                )
257            })
258            .filter(|entry| {
259                entry
260                    .issues
261                    .iter()
262                    .any(|issue| matches!(issue, DiagnosticIssue::Unsupported(_)))
263            })
264            .count();
265
266        assert_eq!(report.totals.unsupported_outputs, unsupported_non_metadata);
267        assert_eq!(
268            report.totals.supported_outputs,
269            report.totals.entries - unsupported_non_metadata
270        );
271    }
272
273    #[test]
274    fn promptbar_hevc_is_supported_as_original_output() {
275        let car = Car::new(fixture_path("Assets.car")).expect("load smoke fixture");
276        let report = car.diagnostics();
277        let promptbar = report
278            .entries
279            .iter()
280            .filter(|entry| entry.facet_name == "PromptBarBkg")
281            .collect::<Vec<_>>();
282
283        assert_eq!(promptbar.len(), 4);
284        assert!(promptbar.iter().all(|entry| {
285            !entry.issues.iter().any(|issue| {
286                matches!(
287                    issue,
288                    DiagnosticIssue::Unsupported(UnsupportedReason::Compression(
289                        CompressionType::HEVC
290                    ))
291                )
292            })
293        }));
294    }
295}