Skip to main content

pleiades_compression/
format.rs

1//! Artifact format and capability profile types.
2
3use core::fmt;
4
5use pleiades_types::CelestialBody;
6
7use crate::channels::ChannelKind;
8use crate::codec::{
9    validate_artifact_profile, validate_canonical_body_order, validate_unique_values,
10};
11use crate::error::{CompressionError, CompressionErrorKind};
12
13/// Describes the byte-order policy encoded by a compressed artifact.
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
16#[non_exhaustive]
17pub enum EndianPolicy {
18    /// The artifact stores its numeric fields in little-endian byte order.
19    LittleEndian,
20}
21
22impl EndianPolicy {
23    /// Returns the compact label used in release-facing summaries.
24    pub const fn label(self) -> &'static str {
25        match self {
26            Self::LittleEndian => "little-endian",
27        }
28    }
29}
30
31impl fmt::Display for EndianPolicy {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(self.label())
34    }
35}
36
37/// Artifact-level output semantics for fields that are not raw segment channels.
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
40#[non_exhaustive]
41pub enum ArtifactOutput {
42    /// Ecliptic coordinates assembled from longitude, latitude, and distance channels.
43    EclipticCoordinates,
44    /// Equatorial coordinates reconstructed from ecliptic coordinates and obliquity policy.
45    EquatorialCoordinates,
46    /// Apparent longitude/latitude corrections such as light-time, aberration, or nutation.
47    ApparentCorrections,
48    /// Topocentric coordinates reconstructed for a terrestrial observer.
49    TopocentricCoordinates,
50    /// Sidereal coordinates derived from tropical coordinates and ayanamsa policy.
51    SiderealCoordinates,
52    /// Longitude/latitude/radial speed values.
53    Motion,
54}
55
56impl ArtifactOutput {
57    /// Returns all built-in artifact outputs in a stable declaration order.
58    pub const fn all() -> [Self; 6] {
59        [
60            Self::EclipticCoordinates,
61            Self::EquatorialCoordinates,
62            Self::ApparentCorrections,
63            Self::TopocentricCoordinates,
64            Self::SiderealCoordinates,
65            Self::Motion,
66        ]
67    }
68
69    pub(crate) const fn ordinal(self) -> u8 {
70        match self {
71            Self::EclipticCoordinates => 0,
72            Self::EquatorialCoordinates => 1,
73            Self::ApparentCorrections => 2,
74            Self::TopocentricCoordinates => 3,
75            Self::SiderealCoordinates => 4,
76            Self::Motion => 5,
77        }
78    }
79
80    /// Returns the compact label used in release-facing summaries.
81    pub const fn label(self) -> &'static str {
82        match self {
83            Self::EclipticCoordinates => "EclipticCoordinates",
84            Self::EquatorialCoordinates => "EquatorialCoordinates",
85            Self::ApparentCorrections => "ApparentCorrections",
86            Self::TopocentricCoordinates => "TopocentricCoordinates",
87            Self::SiderealCoordinates => "SiderealCoordinates",
88            Self::Motion => "Motion",
89        }
90    }
91}
92
93impl fmt::Display for ArtifactOutput {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(self.label())
96    }
97}
98
99/// Describes how a high-level artifact output is represented by the profile.
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
102#[non_exhaustive]
103pub enum ArtifactOutputSupport {
104    /// The output is stored directly in the artifact payload.
105    Stored,
106    /// The output is reconstructed deterministically from stored data.
107    Derived,
108    /// The output is approximated numerically from neighboring decoded data.
109    Approximated,
110    /// The output is explicitly unsupported by the profile.
111    Unsupported,
112    /// The output is neither stored nor explicitly declared by the profile.
113    Unlisted,
114}
115
116impl ArtifactOutputSupport {
117    /// Returns the compact label used in release-facing summaries.
118    pub const fn label(self) -> &'static str {
119        match self {
120            Self::Stored => "stored",
121            Self::Derived => "derived",
122            Self::Approximated => "approximated",
123            Self::Unsupported => "unsupported",
124            Self::Unlisted => "unlisted",
125        }
126    }
127}
128
129impl fmt::Display for ArtifactOutputSupport {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str(self.label())
132    }
133}
134
135/// Declares how motion/speed values are represented by an artifact.
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
138#[non_exhaustive]
139pub enum SpeedPolicy {
140    /// The artifact does not provide speed values.
141    Unsupported,
142    /// Speeds are stored as direct channels.
143    Stored,
144    /// Speeds are derived analytically from fitted segment derivatives.
145    FittedDerivative,
146    /// Speeds are approximated numerically from neighboring decoded samples.
147    NumericalDifference,
148}
149
150impl SpeedPolicy {
151    /// Returns the compact label used in release-facing summaries.
152    pub const fn label(self) -> &'static str {
153        match self {
154            Self::Unsupported => "Unsupported",
155            Self::Stored => "Stored",
156            Self::FittedDerivative => "FittedDerivative",
157            Self::NumericalDifference => "NumericalDifference",
158        }
159    }
160
161    /// Returns how motion/speed output is represented when this policy is used.
162    pub const fn motion_output_support(self) -> ArtifactOutputSupport {
163        match self {
164            Self::Unsupported => ArtifactOutputSupport::Unsupported,
165            Self::Stored => ArtifactOutputSupport::Stored,
166            Self::FittedDerivative => ArtifactOutputSupport::Derived,
167            Self::NumericalDifference => ArtifactOutputSupport::Approximated,
168        }
169    }
170}
171
172impl fmt::Display for SpeedPolicy {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(self.label())
175    }
176}
177
178/// Capability/profile metadata for a compressed artifact.
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180#[derive(Clone, Debug, PartialEq, Eq)]
181pub struct ArtifactProfile {
182    /// Coordinate channels stored directly in each applicable segment.
183    pub stored_channels: Vec<ChannelKind>,
184    /// Higher-level outputs that decoders may derive deterministically from stored data.
185    pub derived_outputs: Vec<ArtifactOutput>,
186    /// Outputs explicitly unsupported by this artifact profile.
187    pub unsupported_outputs: Vec<ArtifactOutput>,
188    /// Motion/speed representation policy.
189    pub speed_policy: SpeedPolicy,
190}
191
192impl ArtifactProfile {
193    /// Creates a profile from explicit fields.
194    pub fn new(
195        stored_channels: Vec<ChannelKind>,
196        derived_outputs: Vec<ArtifactOutput>,
197        unsupported_outputs: Vec<ArtifactOutput>,
198        speed_policy: SpeedPolicy,
199    ) -> Self {
200        Self {
201            stored_channels,
202            derived_outputs,
203            unsupported_outputs,
204            speed_policy,
205        }
206    }
207
208    /// Validates that the profile does not contain duplicate, conflicting, or
209    /// non-canonical entries.
210    ///
211    /// The codec performs the same checks when encoding or decoding artifacts,
212    /// but exposing the validation step directly lets artifact generators fail
213    /// before serialization if they assemble an invalid capability profile.
214    pub fn validate(&self) -> Result<(), CompressionError> {
215        validate_artifact_profile(self)
216    }
217
218    /// Returns a compact one-line summary of the stored, derived, approximated,
219    /// unsupported, and speed-policy capabilities encoded by this profile.
220    pub fn summary(&self) -> String {
221        self.summary_line()
222    }
223
224    /// Returns how motion output is represented by this profile.
225    pub fn motion_output_support(&self) -> ArtifactOutputSupport {
226        self.speed_policy.motion_output_support()
227    }
228
229    /// Returns a compact one-line summary of the stored, derived, approximated,
230    /// unsupported, and speed-policy capabilities encoded by this profile.
231    pub fn summary_line(&self) -> String {
232        format!(
233            "stored channels: {}; derived outputs: {}; unsupported outputs: {}; speed policy: {}",
234            format_bracketed_labels(&self.stored_channels),
235            format_bracketed_labels(&self.derived_outputs),
236            format_bracketed_labels(&self.unsupported_outputs),
237            self.speed_policy,
238        )
239    }
240
241    /// Returns the validated capability summary line.
242    pub fn validated_summary_line(&self) -> Result<String, CompressionError> {
243        self.validate()?;
244        Ok(self.summary_line())
245    }
246
247    /// Returns the validated compact support entries used by the output-support summary.
248    pub fn validated_output_support_entries_summary_line(
249        &self,
250    ) -> Result<String, CompressionError> {
251        self.validate()?;
252        Ok(self.output_support_entries_summary_line())
253    }
254
255    /// Returns the validated output-support summary line.
256    pub fn validated_output_support_summary_line(&self) -> Result<String, CompressionError> {
257        self.validate()?;
258        Ok(self.output_support_summary_line())
259    }
260
261    /// Returns the capability summary annotated with how many bodies share it.
262    pub fn summary_for_body_count(&self, body_count: usize) -> String {
263        format!(
264            "{}; applies to {} bundled bodies",
265            self.summary_line(),
266            body_count
267        )
268    }
269
270    /// Returns the capability summary annotated with how many bodies share it.
271    pub fn summary_line_with_body_count(&self, body_count: usize) -> String {
272        self.summary_for_body_count(body_count)
273    }
274
275    /// Returns the compact support entries used by the output-support summary.
276    pub fn output_support_entries_summary_line(&self) -> String {
277        ArtifactOutput::all()
278            .into_iter()
279            .map(|output| format!("{output}={}", self.output_support(output)))
280            .collect::<Vec<_>>()
281            .join(", ")
282    }
283
284    /// Returns a compact one-line summary of each artifact output's support state.
285    ///
286    /// The rendered line also makes the explicit `unlisted` bucket visible so
287    /// release-facing summaries can fail closed if a built-in output stops being
288    /// classified. It also reports how many outputs are stored, derived,
289    /// approximated, unsupported, or unlisted so the profile makes the support
290    /// buckets explicit without requiring the reader to count them manually.
291    pub fn output_support_summary_line(&self) -> String {
292        let mut stored_count = 0usize;
293        let mut derived_count = 0usize;
294        let mut approximated_count = 0usize;
295        let mut unsupported_count = 0usize;
296        let mut unlisted_count = 0usize;
297        let mut unlisted_outputs = Vec::new();
298
299        for output in ArtifactOutput::all() {
300            match self.output_support(output) {
301                ArtifactOutputSupport::Stored => stored_count += 1,
302                ArtifactOutputSupport::Derived => derived_count += 1,
303                ArtifactOutputSupport::Approximated => approximated_count += 1,
304                ArtifactOutputSupport::Unsupported => unsupported_count += 1,
305                ArtifactOutputSupport::Unlisted => {
306                    unlisted_count += 1;
307                    unlisted_outputs.push(output);
308                }
309            }
310        }
311
312        format!(
313            "{}; unlisted outputs: {}; support counts: stored={}, derived={}, approximated={}, unsupported={}, unlisted={}",
314            self.output_support_entries_summary_line(),
315            format_bracketed_labels(&unlisted_outputs),
316            stored_count,
317            derived_count,
318            approximated_count,
319            unsupported_count,
320            unlisted_count,
321        )
322    }
323
324    /// Returns how a high-level output is represented by this profile.
325    pub fn output_support(&self, output: ArtifactOutput) -> ArtifactOutputSupport {
326        if output == ArtifactOutput::Motion {
327            self.motion_output_support()
328        } else if self.derived_outputs.contains(&output) {
329            ArtifactOutputSupport::Derived
330        } else if self.unsupported_outputs.contains(&output) {
331            ArtifactOutputSupport::Unsupported
332        } else {
333            ArtifactOutputSupport::Unlisted
334        }
335    }
336
337    /// Returns whether the profile can provide the requested output.
338    pub fn supports_output(&self, output: ArtifactOutput) -> bool {
339        matches!(
340            self.output_support(output),
341            ArtifactOutputSupport::Stored
342                | ArtifactOutputSupport::Derived
343                | ArtifactOutputSupport::Approximated
344        )
345    }
346
347    /// Returns whether the profile explicitly marks the output unsupported.
348    pub fn is_unsupported_output(&self, output: ArtifactOutput) -> bool {
349        matches!(
350            self.output_support(output),
351            ArtifactOutputSupport::Unsupported
352        )
353    }
354
355    /// Returns the current packaged-artifact profile shorthand: ecliptic longitude,
356    /// latitude, and distance are stored directly; ecliptic coordinates are
357    /// reconstructed from those channels; equatorial coordinates are derived from
358    /// the stored ecliptic coordinates and mean-obliquity policy; and motion/speed
359    /// is `Motion = Derived` (`SpeedPolicy::FittedDerivative`), not unsupported.
360    pub fn ecliptic_longitude_latitude_distance() -> Self {
361        Self::ecliptic_longitude_latitude_distance_with_derived_equatorial()
362    }
363
364    /// Returns the current packaged-artifact profile with stored ecliptic
365    /// longitude, latitude, and distance channels plus derived equatorial
366    /// coordinates.
367    pub fn packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial() -> Self {
368        Self::new(
369            vec![
370                ChannelKind::Longitude,
371                ChannelKind::Latitude,
372                ChannelKind::DistanceAu,
373            ],
374            vec![
375                ArtifactOutput::EclipticCoordinates,
376                ArtifactOutput::EquatorialCoordinates,
377                ArtifactOutput::Motion,
378            ],
379            vec![
380                ArtifactOutput::ApparentCorrections,
381                ArtifactOutput::TopocentricCoordinates,
382                ArtifactOutput::SiderealCoordinates,
383            ],
384            SpeedPolicy::FittedDerivative,
385        )
386    }
387
388    /// Returns the current packaged-artifact profile with stored ecliptic
389    /// longitude, latitude, and distance channels plus derived equatorial
390    /// coordinates.
391    pub fn ecliptic_longitude_latitude_distance_with_derived_equatorial() -> Self {
392        Self::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial()
393    }
394}
395
396impl fmt::Display for ArtifactProfile {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.write_str(&self.summary_line())
399    }
400}
401
402/// Structured body coverage attached to an artifact capability profile.
403#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
404#[derive(Clone, Debug, PartialEq, Eq)]
405pub struct ArtifactProfileCoverageSummary {
406    /// Number of bundled bodies that share the profile.
407    pub body_count: usize,
408    /// Bodies bundled under the profile.
409    pub bodies: Vec<CelestialBody>,
410    /// Capability profile encoded by the artifact.
411    pub profile: ArtifactProfile,
412}
413
414impl ArtifactProfileCoverageSummary {
415    /// Creates a profile coverage summary from the profile and bundled bodies.
416    pub fn new(profile: ArtifactProfile, bodies: Vec<CelestialBody>) -> Self {
417        let body_count = bodies.len();
418        Self {
419            body_count,
420            bodies,
421            profile,
422        }
423    }
424
425    /// Validates that the summary's body count matches the bundled body list and
426    /// that the embedded artifact profile is internally consistent.
427    pub fn validate(&self) -> Result<(), CompressionError> {
428        self.profile.validate()?;
429        if self.bodies.is_empty() {
430            return Err(CompressionError::new(
431                CompressionErrorKind::InvalidFormat,
432                "artifact profile coverage bundled body list must not be empty",
433            ));
434        }
435        validate_unique_values("artifact profile coverage bundled bodies", &self.bodies)?;
436        validate_canonical_body_order("artifact profile coverage bundled bodies", &self.bodies)?;
437        if self.body_count != self.bodies.len() {
438            return Err(CompressionError::new(
439                CompressionErrorKind::InvalidFormat,
440                "artifact profile coverage body count does not match bundled body list",
441            ));
442        }
443
444        Ok(())
445    }
446
447    /// Returns the capability summary annotated with how many bundled bodies
448    /// currently appear in the summary.
449    pub fn summary_line(&self) -> String {
450        self.profile.summary_for_body_count(self.bodies.len())
451    }
452
453    /// Returns the validated capability summary annotated with how many bundled
454    /// bodies currently appear in the summary.
455    pub fn validated_summary_line(&self) -> Result<String, CompressionError> {
456        self.validate()?;
457        Ok(self.summary_line())
458    }
459
460    /// Returns the capability summary annotated with how many bodies share it
461    /// and lists the bundled bodies explicitly.
462    pub fn summary_line_with_bodies(&self) -> String {
463        format!(
464            "{}; bundled bodies: {}",
465            self.summary_line(),
466            crate::join_display(&self.bodies)
467        )
468    }
469
470    /// Returns the bundled-body summary line after validating the coverage record.
471    pub fn validated_summary_line_with_bodies(&self) -> Result<String, CompressionError> {
472        self.validate()?;
473        Ok(self.summary_line_with_bodies())
474    }
475}
476
477impl fmt::Display for ArtifactProfileCoverageSummary {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        f.write_str(&self.summary_line())
480    }
481}
482
483/// Structured body coverage for residual-correction-bearing segments in a compressed artifact.
484#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
485#[derive(Clone, Debug, PartialEq, Eq)]
486pub struct ArtifactResidualBodyCoverageSummary {
487    /// Number of bundled bodies that include at least one residual-correction segment.
488    pub body_count: usize,
489    /// Bodies that include at least one residual-correction segment.
490    pub bodies: Vec<CelestialBody>,
491}
492
493impl ArtifactResidualBodyCoverageSummary {
494    /// Creates a residual-body coverage summary from an explicit body list.
495    pub fn new(bodies: Vec<CelestialBody>) -> Self {
496        let body_count = bodies.len();
497        Self { body_count, bodies }
498    }
499
500    /// Validates that the summary still matches the current artifact residual-body set.
501    pub fn validate(
502        &self,
503        artifact: &crate::artifact::CompressedArtifact,
504    ) -> Result<(), CompressionError> {
505        let expected_bodies = artifact.residual_bodies();
506
507        if self.body_count != self.bodies.len() {
508            return Err(CompressionError::new(
509                CompressionErrorKind::InvalidFormat,
510                "artifact residual-body coverage body count does not match the body list",
511            ));
512        }
513        validate_unique_values("artifact residual-body coverage bodies", &self.bodies)?;
514
515        if self.body_count != expected_bodies.len() {
516            return Err(CompressionError::new(
517                CompressionErrorKind::InvalidFormat,
518                "artifact residual-body coverage body count does not match residual body list",
519            ));
520        }
521
522        if self.bodies != expected_bodies {
523            return Err(CompressionError::new(
524                CompressionErrorKind::InvalidFormat,
525                "artifact residual-body coverage body list does not match the current artifact",
526            ));
527        }
528
529        Ok(())
530    }
531
532    /// Returns the residual-body coverage as a compact human-readable line.
533    pub fn summary_line(&self) -> String {
534        match self.bodies.as_slice() {
535            [] => "residual bodies: none".to_string(),
536            bodies => format!("residual bodies: {}", crate::join_display(bodies)),
537        }
538    }
539
540    /// Returns the residual-body coverage after validating the artifact.
541    pub fn validated_summary_line(
542        &self,
543        artifact: &crate::artifact::CompressedArtifact,
544    ) -> Result<String, CompressionError> {
545        self.validate(artifact)?;
546        Ok(self.summary_line())
547    }
548
549    /// Returns the residual-body coverage annotated with how many bodies share it.
550    pub fn summary_line_with_body_count(&self) -> String {
551        format!(
552            "{}; applies to {}",
553            self.summary_line(),
554            self.body_count_suffix()
555        )
556    }
557
558    /// Returns the residual-body coverage line after validating the artifact.
559    pub fn validated_summary_line_with_body_count(
560        &self,
561        artifact: &crate::artifact::CompressedArtifact,
562    ) -> Result<String, CompressionError> {
563        let summary = self.validated_summary_line(artifact)?;
564        Ok(format!(
565            "{}; applies to {}",
566            summary,
567            self.body_count_suffix()
568        ))
569    }
570
571    fn body_count_suffix(&self) -> String {
572        match self.body_count {
573            1 => "1 bundled body".to_string(),
574            count => format!("{count} bundled bodies"),
575        }
576    }
577}
578
579impl fmt::Display for ArtifactResidualBodyCoverageSummary {
580    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581        f.write_str(&self.summary_line())
582    }
583}
584
585// ── ArtifactHeader ────────────────────────────────────────────────────────────
586
587/// Describes the non-body metadata stored in a compressed artifact.
588#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
589#[derive(Clone, Debug, PartialEq, Eq)]
590pub struct ArtifactHeader {
591    /// Format version.
592    pub version: u16,
593    /// Human-readable generation label.
594    pub generation_label: String,
595    /// Human-readable provenance/source summary.
596    pub source: String,
597    /// Explicit byte-order policy for the stored numeric fields.
598    pub endian_policy: EndianPolicy,
599    /// Artifact capability profile describing stored, derived, and unsupported outputs.
600    pub profile: ArtifactProfile,
601}
602
603impl ArtifactHeader {
604    /// Creates a new header using the current artifact version, an explicit
605    /// little-endian byte-order policy, and a conservative profile that stores
606    /// only ecliptic longitude/latitude/distance channels (with equatorial
607    /// coordinates and motion derived, and apparent/topocentric/sidereal outputs
608    /// unsupported).
609    pub fn new(generation_label: impl Into<String>, source: impl Into<String>) -> Self {
610        Self::with_profile(
611            generation_label,
612            source,
613            ArtifactProfile::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial(
614            ),
615        )
616    }
617
618    /// Creates a new header using the current artifact version, an explicit
619    /// little-endian byte-order policy, and an explicit profile.
620    pub fn with_profile(
621        generation_label: impl Into<String>,
622        source: impl Into<String>,
623        profile: ArtifactProfile,
624    ) -> Self {
625        Self::with_profile_and_endian(
626            generation_label,
627            source,
628            EndianPolicy::LittleEndian,
629            profile,
630        )
631    }
632
633    /// Creates a new header with an explicit byte-order policy and profile.
634    pub fn with_profile_and_endian(
635        generation_label: impl Into<String>,
636        source: impl Into<String>,
637        endian_policy: EndianPolicy,
638        profile: ArtifactProfile,
639    ) -> Self {
640        Self {
641            version: crate::ARTIFACT_VERSION,
642            generation_label: generation_label.into(),
643            source: source.into(),
644            endian_policy,
645            profile,
646        }
647    }
648
649    /// Returns a compact one-line summary of the byte order and capability
650    /// profile encoded by this header.
651    pub fn summary(&self) -> String {
652        self.summary_line()
653    }
654
655    /// Returns a compact one-line summary of the byte order and capability
656    /// profile encoded by this header.
657    pub fn summary_line(&self) -> String {
658        format!("byte order: {}; {}", self.endian_policy, self.profile)
659    }
660
661    /// Validates that the header's version and provenance fields are populated
662    /// with canonical, non-whitespace-padded text.
663    ///
664    /// The codec already enforces these checks at encode/decode time, but
665    /// exposing the validation step directly lets artifact generators and
666    /// release tooling fail before writing or reusing an invalid header.
667    pub fn validate(&self) -> Result<(), CompressionError> {
668        if self.version != crate::ARTIFACT_VERSION {
669            return Err(CompressionError::new(
670                CompressionErrorKind::InvalidFormat,
671                format!(
672                    "artifact header version {} does not match the current format version {}",
673                    self.version,
674                    crate::ARTIFACT_VERSION
675                ),
676            ));
677        }
678
679        crate::codec::validate_canonical_header_text(
680            "artifact header generation label",
681            &self.generation_label,
682        )?;
683        crate::codec::validate_canonical_header_text("artifact header source", &self.source)?;
684
685        self.profile.validate()
686    }
687
688    /// Returns the header summary annotated with how many bodies share it.
689    pub fn summary_for_body_count(&self, body_count: usize) -> String {
690        format!(
691            "{}; applies to {} bundled bodies",
692            self.summary_line(),
693            body_count
694        )
695    }
696}
697
698impl fmt::Display for ArtifactHeader {
699    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700        f.write_str(&self.summary_line())
701    }
702}
703
704// ── Formatting helpers ────────────────────────────────────────────────────────
705
706pub(crate) fn format_bracketed_labels<T: fmt::Display>(values: &[T]) -> String {
707    format!("[{}]", crate::join_display(values))
708}