Skip to main content

cadmpeg_core/
target.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Encoder target catalogs and typed target-selection refusals.
3
4use std::fmt;
5
6use crate::dialect::DialectId;
7use serde::ser::{SerializeSeq, Serializer};
8use serde::Serialize;
9
10/// One dialect that a caller can request from an encoder.
11///
12/// Synthesis is a static capability. The catalog states ids, aliases, and
13/// the cross-format default; input-conditioned preservation remains a write
14/// planner decision.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct TargetDescriptor {
17    /// Registry dialect id, e.g. `step:ap242-e3`.
18    pub id: DialectId,
19    /// Short spellings accepted for `id`, e.g. `["6"]` for `rhino:archive-60`.
20    pub aliases: &'static [&'static str],
21}
22
23impl TargetDescriptor {
24    const fn token_bytes(&self, index: usize) -> &[u8] {
25        match index {
26            0 => self.id.as_str().as_bytes(),
27            1 => {
28                let bytes = self.id.as_str().as_bytes();
29                let mut start = 0;
30                while bytes[start] != b':' {
31                    start += 1;
32                }
33                bytes.split_at(start + 1).1
34            }
35            _ => self.aliases[index - 2].as_bytes(),
36        }
37    }
38
39    /// Every token accepted for this target: full id, format-local id, and aliases.
40    pub fn accepted_tokens(&self) -> impl Iterator<Item = &str> {
41        std::iter::once(self.id.as_str())
42            .chain(std::iter::once(self.id.local()))
43            .chain(self.aliases.iter().copied())
44    }
45}
46
47/// An encoder's synthesis targets and its optional cross-format default.
48///
49/// The default is a position in `targets`, validated when the catalog is
50/// constructed. A catalog may have no default when the encoder cannot
51/// synthesize a document from a source of another format.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct TargetCatalog(CatalogEntries);
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum CatalogEntries {
57    Empty(&'static str),
58    Rows {
59        targets: &'static [TargetDescriptor],
60        default: Option<usize>,
61    },
62}
63
64impl TargetCatalog {
65    /// Builds an empty catalog for a dialect-free format.
66    #[must_use]
67    pub const fn empty(namespace: &'static str) -> Self {
68        Self(CatalogEntries::Empty(namespace))
69    }
70
71    /// Builds a nonempty catalog with one namespace and an optional default row.
72    ///
73    /// Panics if rows have different namespaces, a spelling selects multiple
74    /// rows, or the default is out of bounds.
75    #[must_use]
76    pub const fn new(targets: &'static [TargetDescriptor], default: Option<usize>) -> Self {
77        let [first, rest @ ..] = targets else {
78            panic!("use TargetCatalog::empty for a dialect-free format");
79        };
80        let first = first.id.as_str().as_bytes();
81        let mut row = 0;
82        while row < rest.len() {
83            let other = rest[row].id.as_str().as_bytes();
84            let mut byte = 0;
85            while byte < first.len() && byte < other.len() {
86                assert!(
87                    first[byte] == other[byte],
88                    "target catalog rows must share a namespace"
89                );
90                if first[byte] == b':' {
91                    break;
92                }
93                byte += 1;
94            }
95            row += 1;
96        }
97        let mut left = 0;
98        while left < targets.len() {
99            let mut right = left + 1;
100            while right < targets.len() {
101                let mut left_token = 0;
102                while left_token < targets[left].aliases.len() + 2 {
103                    let mut right_token = 0;
104                    while right_token < targets[right].aliases.len() + 2 {
105                        assert!(
106                            !token_bytes_equal(
107                                targets[left].token_bytes(left_token),
108                                targets[right].token_bytes(right_token),
109                            ),
110                            "target catalog spellings must select one row"
111                        );
112                        right_token += 1;
113                    }
114                    left_token += 1;
115                }
116                right += 1;
117            }
118            left += 1;
119        }
120        if let Some(index) = default {
121            assert!(
122                index < targets.len(),
123                "target catalog default is out of bounds"
124            );
125        }
126        Self(CatalogEntries::Rows { targets, default })
127    }
128
129    /// Returns the format namespace owned by this catalog.
130    #[must_use]
131    pub fn namespace(self) -> &'static str {
132        match self.0 {
133            CatalogEntries::Empty(namespace) => namespace,
134            CatalogEntries::Rows { targets, .. } => targets[0].id.namespace(),
135        }
136    }
137
138    /// Returns all synthesis targets in catalog order.
139    #[must_use]
140    pub const fn targets(self) -> &'static [TargetDescriptor] {
141        match self.0 {
142            CatalogEntries::Empty(_) => &[],
143            CatalogEntries::Rows { targets, .. } => targets,
144        }
145    }
146
147    /// Returns whether this catalog has no synthesis targets.
148    #[must_use]
149    pub const fn is_empty(self) -> bool {
150        matches!(self.0, CatalogEntries::Empty(_))
151    }
152
153    /// Returns the number of synthesis targets.
154    #[must_use]
155    pub const fn len(self) -> usize {
156        self.targets().len()
157    }
158
159    /// Iterates over synthesis targets in catalog order.
160    pub fn iter(self) -> std::slice::Iter<'static, TargetDescriptor> {
161        self.targets().iter()
162    }
163
164    /// Returns the row named by a full id, format-local id, or alias, with its position.
165    #[must_use]
166    pub fn find(self, token: &str) -> Option<(usize, &'static TargetDescriptor)> {
167        self.iter()
168            .enumerate()
169            .find(|(_, target)| target.accepted_tokens().any(|accepted| accepted == token))
170    }
171
172    /// Returns the cross-format default and its position, when declared.
173    #[must_use]
174    pub fn default(self) -> Option<(usize, &'static TargetDescriptor)> {
175        match self.0 {
176            CatalogEntries::Empty(_) => None,
177            CatalogEntries::Rows { targets, default } => {
178                default.map(|index| (index, &targets[index]))
179            }
180        }
181    }
182}
183
184impl IntoIterator for TargetCatalog {
185    type Item = &'static TargetDescriptor;
186    type IntoIter = std::slice::Iter<'static, TargetDescriptor>;
187
188    fn into_iter(self) -> Self::IntoIter {
189        self.iter()
190    }
191}
192
193impl Serialize for TargetCatalog {
194    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
195    where
196        S: Serializer,
197    {
198        #[derive(Serialize)]
199        struct TargetDescriptorWire<'a> {
200            id: &'a DialectId,
201            aliases: &'static [&'static str],
202            default: bool,
203        }
204
205        let mut sequence = serializer.serialize_seq(Some(self.len()))?;
206        for (index, target) in self.iter().enumerate() {
207            sequence.serialize_element(&TargetDescriptorWire {
208                id: &target.id,
209                aliases: target.aliases,
210                default: self.default().is_some_and(|(default, _)| default == index),
211            })?;
212        }
213        sequence.end()
214    }
215}
216
217const fn token_bytes_equal(left: &[u8], right: &[u8]) -> bool {
218    if left.len() != right.len() {
219        return false;
220    }
221    let mut index = 0;
222    while index < left.len() {
223        if left[index] != right[index] {
224            return false;
225        }
226        index += 1;
227    }
228    true
229}
230
231/// A caller-supplied dialect token requested from an encoder.
232///
233/// Unlike [`DialectId`], this token need not name a registered dialect:
234/// refusals preserve an unknown explicit request verbatim.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
236#[serde(transparent)]
237pub struct TargetToken(String);
238
239impl TargetToken {
240    /// Retains one requested target token verbatim.
241    #[must_use]
242    pub fn new(token: impl Into<String>) -> Self {
243        Self(token.into())
244    }
245
246    /// Returns the requested token.
247    #[must_use]
248    pub fn as_str(&self) -> &str {
249        &self.0
250    }
251}
252
253impl fmt::Display for TargetToken {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        f.write_str(self.as_str())
256    }
257}
258
259/// One target-refusal reason, independent of the catalog presented with it.
260///
261/// Each request state is distinct. In particular, absence of an explicit
262/// token does not conflate an unclassified same-format source with a foreign
263/// source and a missing cross-format default.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265#[non_exhaustive]
266#[serde(rename_all = "snake_case", tag = "kind")]
267pub enum TargetRefusalKind {
268    /// An explicit token names no entry in the encoder catalog.
269    UnknownExplicit {
270        /// Token supplied by the caller, retained verbatim.
271        requested: TargetToken,
272    },
273    /// A catalog target was selected explicitly but this input cannot reach it.
274    ExplicitUnavailable {
275        /// Canonical catalog target selected by the token.
276        target: DialectId,
277        /// Token supplied by the caller, retained verbatim.
278        requested: TargetToken,
279        /// Input-conditioned reason the writer cannot deliver the target.
280        reason: String,
281    },
282    /// Same-format inheritance selected a source dialect the writer cannot preserve.
283    InheritedUnavailable {
284        /// Recorded source dialect selected by inheritance.
285        source: DialectId,
286        /// Input-conditioned reason the writer cannot preserve it.
287        reason: String,
288    },
289    /// Same-format inheritance found source metadata without a dialect.
290    UnrecordedSource,
291    /// Inheritance had no same-format source and the catalog declares no default.
292    NoDefault {
293        /// Why no same-format source identity was available to inherit.
294        source: DefaultSource,
295    },
296    /// A cross-format default was selected but this input cannot reach it.
297    DefaultUnavailable {
298        /// Canonical catalog default.
299        target: DialectId,
300        /// Why the catalog default, rather than source inheritance, was selected.
301        source: DefaultSource,
302        /// Input-conditioned reason the writer cannot deliver the target.
303        reason: String,
304    },
305}
306
307/// Why an encoder could not resolve or deliver a write target.
308///
309/// The refusal carries the encoder catalog once, beside the request-state
310/// reason, so every reason is rendered and reported against the same catalog.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct TargetRefusal {
313    kind: TargetRefusalKind,
314    available: TargetCatalog,
315}
316
317impl Serialize for TargetRefusal {
318    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
319        #[derive(Serialize)]
320        struct Wire<'a> {
321            format: &'a str,
322            refusal: &'a TargetRefusalKind,
323            available: TargetCatalog,
324        }
325        Wire {
326            format: self.format(),
327            refusal: &self.kind,
328            available: self.available,
329        }
330        .serialize(serializer)
331    }
332}
333
334/// Why inheritance must select an encoder catalog default.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
336#[serde(rename_all = "snake_case", tag = "kind", content = "format")]
337pub enum DefaultSource {
338    /// The document records no source metadata.
339    NoSource,
340    /// The document's source belongs to another format.
341    ForeignFormat(String),
342}
343
344impl TargetRefusal {
345    /// Associates one request-state reason with the refusing encoder catalog.
346    #[must_use]
347    pub fn new(kind: TargetRefusalKind, available: TargetCatalog) -> Self {
348        Self { kind, available }
349    }
350
351    /// Builds the refusal for an explicit token outside an encoder catalog.
352    #[must_use]
353    pub fn unknown_explicit(requested: impl Into<String>, available: TargetCatalog) -> Self {
354        Self::new(
355            TargetRefusalKind::UnknownExplicit {
356                requested: TargetToken::new(requested),
357            },
358            available,
359        )
360    }
361
362    /// Returns the refusing encoder format.
363    #[must_use]
364    pub fn format(&self) -> &str {
365        self.available.namespace()
366    }
367
368    /// Returns the encoder's structured synthesis catalog.
369    #[must_use]
370    pub const fn available(&self) -> &'static [TargetDescriptor] {
371        self.available.targets()
372    }
373
374    fn write_available(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        f.write_str("; available targets: ")?;
376        let mut targets = self.available().iter();
377        let Some(first) = targets.next() else {
378            return f.write_str("none");
379        };
380        f.write_str(first.id.as_str())?;
381        for target in targets {
382            write!(f, ", {}", target.id)?;
383        }
384        Ok(())
385    }
386}
387
388impl fmt::Display for TargetRefusal {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        let format = self.format();
391        match &self.kind {
392            TargetRefusalKind::UnknownExplicit { requested } => write!(
393                f,
394                "{format} cannot write {requested}: not a target this encoder can synthesize"
395            )?,
396            TargetRefusalKind::ExplicitUnavailable {
397                target,
398                requested,
399                reason,
400                ..
401            } => write!(
402                f,
403                "{format} cannot write explicit target {requested} ({target}): {reason}"
404            )?,
405            TargetRefusalKind::InheritedUnavailable { source, reason } => write!(
406                f,
407                "{format} cannot preserve source dialect {source}: {reason}"
408            )?,
409            TargetRefusalKind::UnrecordedSource => write!(
410                f,
411                "{format} cannot inherit a write target: the {format} source records no dialect; name an explicit target"
412            )?,
413            TargetRefusalKind::NoDefault {
414                source: DefaultSource::ForeignFormat(source_format),
415            } => write!(
416                f,
417                "{format} cannot inherit a write target from source format {source_format}: this encoder declares no cross-format default"
418            )?,
419            TargetRefusalKind::NoDefault {
420                source: DefaultSource::NoSource,
421            } => write!(
422                f,
423                "{format} cannot select an inherited write target: the document records no source format and this encoder declares no default"
424            )?,
425            TargetRefusalKind::DefaultUnavailable {
426                target,
427                source,
428                reason,
429            } => {
430                write!(f, "{format} cannot write default target {target}")?;
431                if let DefaultSource::ForeignFormat(source_format) = source {
432                    write!(f, " selected for source format {source_format}")?;
433                }
434                write!(f, ": {reason}")?;
435            }
436        }
437        self.write_available(f)
438    }
439}
440
441impl std::error::Error for TargetRefusal {}
442
443#[cfg(test)]
444mod tests {
445    use super::{
446        DefaultSource, TargetCatalog, TargetDescriptor, TargetRefusal, TargetRefusalKind,
447        TargetToken,
448    };
449    use crate::dialect::DialectId;
450
451    const NO_ALIASES: &[&str] = &[];
452
453    const TARGETS: &[TargetDescriptor] = &[TargetDescriptor {
454        id: crate::dialect_id!("fcstd:schema-4"),
455        aliases: &["4"],
456    }];
457
458    const fn target(id: &'static str, aliases: &'static [&'static str]) -> TargetDescriptor {
459        TargetDescriptor {
460            id: DialectId::from_static(
461                crate::dialect::StaticDialectId::new(id).expect("test id has dialect grammar"),
462            ),
463            aliases,
464        }
465    }
466
467    #[test]
468    fn target_refusal_serializes_request_state_and_the_complete_catalog() {
469        let refusal = TargetRefusal::new(
470            TargetRefusalKind::ExplicitUnavailable {
471                target: crate::dialect_id!("fcstd:schema-4"),
472                requested: TargetToken::new("4"),
473                reason: "the source image cannot be patched".into(),
474            },
475            TargetCatalog::new(TARGETS, None),
476        );
477
478        assert_eq!(
479            serde_json::to_value(refusal).expect("target refusal serializes"),
480            serde_json::json!({
481                "format": "fcstd",
482                "refusal": {
483                    "kind": "explicit_unavailable",
484                    "target": "fcstd:schema-4",
485                    "requested": "4",
486                    "reason": "the source image cannot be patched",
487                },
488                "available": [{
489                    "id": "fcstd:schema-4",
490                    "aliases": ["4"],
491                    "default": false
492                }]
493            })
494        );
495    }
496
497    #[test]
498    #[should_panic(expected = "target catalog rows must share a namespace")]
499    fn target_catalog_rejects_mixed_namespaces() {
500        static MIXED: &[TargetDescriptor] =
501            &[target("fcstd:schema-4", &[]), target("step:ap242-e3", &[])];
502        let _catalog = TargetCatalog::new(MIXED, None);
503    }
504
505    #[test]
506    fn empty_catalog_refusal_retains_its_format() {
507        let refusal = TargetRefusal::unknown_explicit("future", TargetCatalog::empty("cadir"));
508        assert_eq!(
509            serde_json::to_value(&refusal).expect("serialize target refusal"),
510            serde_json::json!({
511                "format": "cadir",
512                "refusal": {"kind": "unknown_explicit", "requested": "future"},
513                "available": []
514            })
515        );
516        assert_eq!(refusal.to_string(), "cadir cannot write future: not a target this encoder can synthesize; available targets: none");
517    }
518
519    #[test]
520    fn target_lookup_accepts_each_owned_spelling_and_rejects_a_miss() {
521        const TARGETS: &[TargetDescriptor] = &[target("test:first", &["one", "primary"])];
522        let catalog = TargetCatalog::new(TARGETS, None);
523
524        assert_eq!(
525            TARGETS[0].accepted_tokens().collect::<Vec<_>>(),
526            vec!["test:first", "first", "one", "primary"]
527        );
528        for token in ["test:first", "first", "one", "primary"] {
529            assert_eq!(
530                catalog.find(token).map(|(_, entry)| entry.id.as_str()),
531                Some("test:first"),
532                "lookup failed for {token:?}"
533            );
534        }
535        assert_eq!(catalog.find("missing"), None);
536    }
537
538    #[test]
539    #[should_panic(expected = "target catalog default is out of bounds")]
540    fn a_target_catalog_rejects_an_invalid_default_index() {
541        let _catalog = TargetCatalog::new(TARGETS, Some(TARGETS.len()));
542    }
543
544    #[test]
545    fn a_target_catalog_allows_repeated_spellings_within_one_row() {
546        const TARGETS: &[TargetDescriptor] = &[
547            target("test:first", &["first", "test:first", "one", "one"]),
548            target("test:second", &["second", "two"]),
549        ];
550        const CATALOG: TargetCatalog = TargetCatalog::new(TARGETS, Some(0));
551        for token in ["first", "test:first", "one"] {
552            assert_eq!(CATALOG.find(token).map(|(index, _)| index), Some(0));
553        }
554        for token in ["second", "test:second", "two"] {
555            assert_eq!(CATALOG.find(token).map(|(index, _)| index), Some(1));
556        }
557    }
558
559    #[test]
560    #[should_panic(expected = "target catalog spellings must select one row")]
561    fn a_target_catalog_rejects_duplicate_ids() {
562        const DUPLICATES: &[TargetDescriptor] = &[
563            target("test:same", NO_ALIASES),
564            target("test:same", NO_ALIASES),
565        ];
566        let _catalog = TargetCatalog::new(DUPLICATES, None);
567    }
568
569    #[test]
570    #[should_panic(expected = "target catalog spellings must select one row")]
571    fn a_target_catalog_rejects_duplicate_aliases() {
572        const DUPLICATES: &[TargetDescriptor] = &[
573            target("test:first", &["same"]),
574            target("test:second", &["same"]),
575        ];
576        let _catalog = TargetCatalog::new(DUPLICATES, None);
577    }
578
579    #[test]
580    #[should_panic(expected = "target catalog spellings must select one row")]
581    fn a_target_catalog_rejects_an_alias_that_is_an_id() {
582        const DUPLICATES: &[TargetDescriptor] = &[
583            target("test:first", &["test:second"]),
584            target("test:second", NO_ALIASES),
585        ];
586        let _catalog = TargetCatalog::new(DUPLICATES, None);
587    }
588
589    #[test]
590    #[should_panic(expected = "target catalog spellings must select one row")]
591    fn a_target_catalog_rejects_an_alias_that_is_another_rows_local_id() {
592        const DUPLICATES: &[TargetDescriptor] = &[
593            target("test:first", &["second"]),
594            target("test:second", NO_ALIASES),
595        ];
596        let _catalog = TargetCatalog::new(DUPLICATES, None);
597    }
598
599    #[test]
600    fn missing_default_names_a_foreign_source_without_inventing_a_dialect() {
601        let refusal = TargetRefusal::new(
602            TargetRefusalKind::NoDefault {
603                source: DefaultSource::ForeignFormat("step".into()),
604            },
605            TargetCatalog::new(TARGETS, None),
606        );
607
608        assert_eq!(
609            ({
610                let wire = serde_json::to_value(&refusal).expect("serialize refusal");
611                wire["refusal"]
612                    .get("requested")
613                    .or_else(|| wire["refusal"].get("source"))
614                    .and_then(serde_json::Value::as_str)
615                    .map(str::to_owned)
616            })
617            .as_deref(),
618            None
619        );
620        assert_eq!(refusal.available(), TARGETS);
621        assert_eq!(
622            refusal.to_string(),
623            "fcstd cannot inherit a write target from source format step: this encoder declares no cross-format default; available targets: fcstd:schema-4"
624        );
625    }
626
627    #[test]
628    fn unrecorded_same_format_source_is_not_a_missing_default() {
629        let refusal = TargetRefusal::new(
630            TargetRefusalKind::UnrecordedSource,
631            TargetCatalog::new(TARGETS, None),
632        );
633
634        assert_eq!(
635            refusal.to_string(),
636            "fcstd cannot inherit a write target: the fcstd source records no dialect; name an explicit target; available targets: fcstd:schema-4"
637        );
638    }
639}