1use std::fmt;
5
6use crate::dialect::DialectId;
7use serde::ser::{SerializeSeq, Serializer};
8use serde::Serialize;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct TargetDescriptor {
17 pub id: DialectId,
19 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 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#[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 #[must_use]
67 pub const fn empty(namespace: &'static str) -> Self {
68 Self(CatalogEntries::Empty(namespace))
69 }
70
71 #[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 #[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 #[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 #[must_use]
149 pub const fn is_empty(self) -> bool {
150 matches!(self.0, CatalogEntries::Empty(_))
151 }
152
153 #[must_use]
155 pub const fn len(self) -> usize {
156 self.targets().len()
157 }
158
159 pub fn iter(self) -> std::slice::Iter<'static, TargetDescriptor> {
161 self.targets().iter()
162 }
163
164 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
236#[serde(transparent)]
237pub struct TargetToken(String);
238
239impl TargetToken {
240 #[must_use]
242 pub fn new(token: impl Into<String>) -> Self {
243 Self(token.into())
244 }
245
246 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265#[non_exhaustive]
266#[serde(rename_all = "snake_case", tag = "kind")]
267pub enum TargetRefusalKind {
268 UnknownExplicit {
270 requested: TargetToken,
272 },
273 ExplicitUnavailable {
275 target: DialectId,
277 requested: TargetToken,
279 reason: String,
281 },
282 InheritedUnavailable {
284 source: DialectId,
286 reason: String,
288 },
289 UnrecordedSource,
291 NoDefault {
293 source: DefaultSource,
295 },
296 DefaultUnavailable {
298 target: DialectId,
300 source: DefaultSource,
302 reason: String,
304 },
305}
306
307#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
336#[serde(rename_all = "snake_case", tag = "kind", content = "format")]
337pub enum DefaultSource {
338 NoSource,
340 ForeignFormat(String),
342}
343
344impl TargetRefusal {
345 #[must_use]
347 pub fn new(kind: TargetRefusalKind, available: TargetCatalog) -> Self {
348 Self { kind, available }
349 }
350
351 #[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 #[must_use]
364 pub fn format(&self) -> &str {
365 self.available.namespace()
366 }
367
368 #[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}