Skip to main content

es_fluent_manager_core/
asset_localization.rs

1//! Shared module metadata and discovery contracts.
2
3use fluent_bundle::FluentResource;
4use std::collections::HashSet;
5use std::fmt;
6use std::sync::Arc;
7use unic_langid::LanguageIdentifier;
8
9/// Stable key for a localized resource.
10///
11/// Keys use the canonical shape:
12/// - `{domain}` for base files
13/// - `{domain}/{namespace}` for namespaced files
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct ResourceKey(String);
16
17impl ResourceKey {
18    /// Creates a new resource key.
19    pub fn new(key: impl Into<String>) -> Self {
20        Self(key.into())
21    }
22
23    /// Returns the key as a string slice.
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27
28    /// Returns the domain segment of the key.
29    pub fn domain(&self) -> &str {
30        self.0.split('/').next().unwrap_or(self.as_str())
31    }
32}
33
34impl AsRef<str> for ResourceKey {
35    fn as_ref(&self) -> &str {
36        self.as_str()
37    }
38}
39
40impl From<String> for ResourceKey {
41    fn from(value: String) -> Self {
42        Self::new(value)
43    }
44}
45
46impl From<&str> for ResourceKey {
47    fn from(value: &str) -> Self {
48        Self::new(value.to_string())
49    }
50}
51
52impl fmt::Display for ResourceKey {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58/// Static metadata describing an i18n module.
59///
60/// This single shape is shared by all managers (embedded, Bevy, and future
61/// third-party backends) so module discovery and routing can be standardized.
62#[derive(Debug)]
63pub struct ModuleData {
64    /// The unique module name (typically crate name).
65    pub name: &'static str,
66    /// The Fluent domain for this module.
67    pub domain: &'static str,
68    /// Languages that this module can provide.
69    pub supported_languages: &'static [LanguageIdentifier],
70    /// Namespaces used by the module (e.g., "ui", "errors").
71    /// If empty, only the main `{domain}.ftl` file is used.
72    /// If non-empty, namespace files are the canonical resources and managers
73    /// treat `{domain}.ftl` as optional compatibility data.
74    pub namespaces: &'static [&'static str],
75}
76
77impl ModuleData {
78    /// Returns the canonical resource plan for this module.
79    pub fn resource_plan(&self) -> Vec<ModuleResourceSpec> {
80        resource_plan_for(self.domain, self.namespaces)
81    }
82}
83
84/// Validation failures for a discovered module registry.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum ModuleRegistryError {
87    /// A module has an empty name.
88    EmptyModuleName,
89    /// A module has an empty domain.
90    EmptyDomain { module: String },
91    /// A module name appears more than once.
92    DuplicateModuleName { name: String },
93    /// A domain appears more than once.
94    DuplicateDomain { domain: String },
95    /// A module declares the same language more than once.
96    DuplicateSupportedLanguage {
97        module: String,
98        language: LanguageIdentifier,
99    },
100    /// A module declares the same namespace more than once.
101    DuplicateNamespace { module: String, namespace: String },
102    /// A namespace entry is malformed.
103    InvalidNamespace {
104        module: String,
105        namespace: String,
106        details: &'static str,
107    },
108}
109
110impl fmt::Display for ModuleRegistryError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::EmptyModuleName => write!(f, "module name must not be empty"),
114            Self::EmptyDomain { module } => {
115                write!(f, "module '{}' has an empty domain", module)
116            },
117            Self::DuplicateModuleName { name } => {
118                write!(f, "duplicate module name '{}'", name)
119            },
120            Self::DuplicateDomain { domain } => {
121                write!(f, "duplicate module domain '{}'", domain)
122            },
123            Self::DuplicateSupportedLanguage { module, language } => write!(
124                f,
125                "module '{}' declares duplicate language '{}'",
126                module, language
127            ),
128            Self::DuplicateNamespace { module, namespace } => write!(
129                f,
130                "module '{}' declares duplicate namespace '{}'",
131                module, namespace
132            ),
133            Self::InvalidNamespace {
134                module,
135                namespace,
136                details,
137            } => write!(
138                f,
139                "module '{}' has invalid namespace '{}': {}",
140                module, namespace, details
141            ),
142        }
143    }
144}
145
146impl std::error::Error for ModuleRegistryError {}
147
148/// Validates module metadata discovered through inventory.
149///
150/// Contract:
151/// - `name` and `domain` must be non-empty.
152/// - `name` and `domain` must be globally unique.
153/// - `supported_languages` and `namespaces` must not contain duplicates.
154/// - Namespaces must be bare namespace names (no slash and no `.ftl` suffix).
155pub fn validate_module_registry<'a>(
156    modules: impl IntoIterator<Item = &'a ModuleData>,
157) -> Result<(), Vec<ModuleRegistryError>> {
158    let mut errors = Vec::new();
159    let mut module_names = HashSet::new();
160    let mut module_domains = HashSet::new();
161
162    for data in modules {
163        if data.name.trim().is_empty() {
164            errors.push(ModuleRegistryError::EmptyModuleName);
165        } else if !module_names.insert(data.name) {
166            errors.push(ModuleRegistryError::DuplicateModuleName {
167                name: data.name.to_string(),
168            });
169        }
170
171        if data.domain.trim().is_empty() {
172            errors.push(ModuleRegistryError::EmptyDomain {
173                module: data.name.to_string(),
174            });
175        } else if !module_domains.insert(data.domain) {
176            errors.push(ModuleRegistryError::DuplicateDomain {
177                domain: data.domain.to_string(),
178            });
179        }
180
181        let mut seen_languages = HashSet::new();
182        for lang in data.supported_languages {
183            if !seen_languages.insert(lang.clone()) {
184                errors.push(ModuleRegistryError::DuplicateSupportedLanguage {
185                    module: data.name.to_string(),
186                    language: lang.clone(),
187                });
188            }
189        }
190
191        let mut seen_namespaces = HashSet::new();
192        for namespace in data.namespaces {
193            let trimmed = namespace.trim();
194            if trimmed.is_empty() {
195                errors.push(ModuleRegistryError::InvalidNamespace {
196                    module: data.name.to_string(),
197                    namespace: namespace.to_string(),
198                    details: "namespace must not be empty",
199                });
200                continue;
201            }
202            if trimmed.contains('/') {
203                errors.push(ModuleRegistryError::InvalidNamespace {
204                    module: data.name.to_string(),
205                    namespace: namespace.to_string(),
206                    details: "namespace must not contain '/'",
207                });
208            }
209            if trimmed.ends_with(".ftl") {
210                errors.push(ModuleRegistryError::InvalidNamespace {
211                    module: data.name.to_string(),
212                    namespace: namespace.to_string(),
213                    details: "namespace must not include file extension",
214                });
215            }
216            if !seen_namespaces.insert(trimmed) {
217                errors.push(ModuleRegistryError::DuplicateNamespace {
218                    module: data.name.to_string(),
219                    namespace: trimmed.to_string(),
220                });
221            }
222        }
223    }
224
225    if errors.is_empty() {
226        Ok(())
227    } else {
228        Err(errors)
229    }
230}
231
232/// Canonical description of a single localized resource file.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub struct ModuleResourceSpec {
235    /// Stable resource key used by managers (e.g., `my-crate`, `my-crate/ui`).
236    pub key: ResourceKey,
237    /// Path under a locale root (e.g., `my-crate.ftl`, `my-crate/ui.ftl`).
238    pub locale_relative_path: String,
239    /// Whether this resource is required for locale readiness.
240    pub required: bool,
241}
242
243impl ModuleResourceSpec {
244    /// Returns the full path for a locale (e.g., `en/my-crate.ftl`).
245    pub fn locale_path(&self, lang: &LanguageIdentifier) -> String {
246        format!("{}/{}", lang, self.locale_relative_path)
247    }
248}
249
250fn module_resource_spec(
251    key: impl Into<ResourceKey>,
252    locale_relative_path: impl Into<String>,
253    required: bool,
254) -> ModuleResourceSpec {
255    ModuleResourceSpec {
256        key: key.into(),
257        locale_relative_path: locale_relative_path.into(),
258        required,
259    }
260}
261
262/// Builds a canonical resource plan for a domain.
263///
264/// Contract:
265/// - Without namespaces, `{domain}.ftl` is required.
266/// - With namespaces, `{domain}.ftl` is optional compatibility data and
267///   `{domain}/{namespace}.ftl` entries are required.
268pub fn resource_plan_for(domain: &str, namespaces: &[&str]) -> Vec<ModuleResourceSpec> {
269    if namespaces.is_empty() {
270        return vec![module_resource_spec(
271            ResourceKey::new(domain.to_string()),
272            format!("{domain}.ftl"),
273            true,
274        )];
275    }
276
277    let mut plan = Vec::with_capacity(namespaces.len() + 1);
278    plan.push(module_resource_spec(
279        ResourceKey::new(domain.to_string()),
280        format!("{domain}.ftl"),
281        false,
282    ));
283
284    let mut seen = HashSet::new();
285    for namespace in namespaces {
286        if !seen.insert(*namespace) {
287            continue;
288        }
289
290        plan.push(module_resource_spec(
291            ResourceKey::new(format!("{domain}/{namespace}")),
292            format!("{domain}/{namespace}.ftl"),
293            true,
294        ));
295    }
296
297    plan
298}
299
300/// Returns required resource keys from a resource plan.
301pub fn required_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
302    plan.iter()
303        .filter(|spec| spec.required)
304        .map(|spec| spec.key.clone())
305        .collect()
306}
307
308/// Returns optional resource keys from a resource plan.
309pub fn optional_resource_keys_from_plan(plan: &[ModuleResourceSpec]) -> HashSet<ResourceKey> {
310    plan.iter()
311        .filter(|spec| !spec.required)
312        .map(|spec| spec.key.clone())
313        .collect()
314}
315
316/// Returns true when all required keys are present in the loaded set.
317pub fn locale_is_ready(
318    required_keys: &HashSet<ResourceKey>,
319    loaded_keys: &HashSet<ResourceKey>,
320) -> bool {
321    required_keys.iter().all(|key| loaded_keys.contains(key))
322}
323
324/// Structured locale loading state shared across managers.
325#[derive(Clone, Debug, Default)]
326pub struct LocaleLoadReport {
327    required_keys: HashSet<ResourceKey>,
328    optional_keys: HashSet<ResourceKey>,
329    loaded_keys: HashSet<ResourceKey>,
330    errors: Vec<ResourceLoadError>,
331}
332
333impl LocaleLoadReport {
334    /// Builds a new report from a canonical resource plan.
335    pub fn from_plan(plan: &[ModuleResourceSpec]) -> Self {
336        Self::from_specs(plan.iter())
337    }
338
339    /// Builds a new report from resource specs.
340    pub fn from_specs<'a>(specs: impl IntoIterator<Item = &'a ModuleResourceSpec>) -> Self {
341        let mut required_keys = HashSet::new();
342        let mut optional_keys = HashSet::new();
343
344        for spec in specs {
345            if spec.required {
346                required_keys.insert(spec.key.clone());
347            } else {
348                optional_keys.insert(spec.key.clone());
349            }
350        }
351
352        Self {
353            required_keys,
354            optional_keys,
355            loaded_keys: HashSet::new(),
356            errors: Vec::new(),
357        }
358    }
359
360    /// Marks a resource key as loaded.
361    pub fn mark_loaded(&mut self, key: ResourceKey) {
362        self.loaded_keys.insert(key);
363    }
364
365    /// Records a resource load error and removes the corresponding loaded key.
366    pub fn record_error(&mut self, error: ResourceLoadError) {
367        self.loaded_keys.remove(error.key());
368        self.errors.push(error);
369    }
370
371    /// Returns required keys from the report.
372    pub fn required_keys(&self) -> &HashSet<ResourceKey> {
373        &self.required_keys
374    }
375
376    /// Returns optional keys from the report.
377    pub fn optional_keys(&self) -> &HashSet<ResourceKey> {
378        &self.optional_keys
379    }
380
381    /// Returns loaded keys from the report.
382    pub fn loaded_keys(&self) -> &HashSet<ResourceKey> {
383        &self.loaded_keys
384    }
385
386    /// Returns all recorded load errors.
387    pub fn errors(&self) -> &[ResourceLoadError] {
388        &self.errors
389    }
390
391    /// Returns required keys that are still missing.
392    pub fn missing_required_keys(&self) -> HashSet<ResourceKey> {
393        self.required_keys
394            .iter()
395            .filter(|key| !self.loaded_keys.contains(*key))
396            .cloned()
397            .collect()
398    }
399
400    /// Returns true when a required resource failed loading.
401    pub fn has_required_errors(&self) -> bool {
402        self.errors.iter().any(ResourceLoadError::is_required)
403    }
404
405    /// Returns true when locale readiness requirements are met.
406    pub fn is_ready(&self) -> bool {
407        locale_is_ready(&self.required_keys, &self.loaded_keys) && !self.has_required_errors()
408    }
409}
410
411/// Canonical resource-load failure categories shared across managers.
412#[derive(Clone, Debug, Eq, PartialEq)]
413pub enum ResourceLoadError {
414    /// A required resource was not present.
415    Missing {
416        key: ResourceKey,
417        path: String,
418        required: bool,
419    },
420    /// Resource bytes were not valid UTF-8.
421    InvalidUtf8 {
422        key: ResourceKey,
423        path: String,
424        required: bool,
425        details: String,
426    },
427    /// Resource content failed Fluent parsing.
428    Parse {
429        key: ResourceKey,
430        path: String,
431        required: bool,
432        details: String,
433    },
434    /// Resource loading failed in the host asset pipeline.
435    Load {
436        key: ResourceKey,
437        path: String,
438        required: bool,
439        details: String,
440    },
441}
442
443impl ResourceLoadError {
444    /// Constructs a missing-file error for a resource spec.
445    pub fn missing(spec: &ModuleResourceSpec) -> Self {
446        Self::Missing {
447            key: spec.key.clone(),
448            path: spec.locale_relative_path.clone(),
449            required: spec.required,
450        }
451    }
452
453    /// Constructs an asset-pipeline load error for a resource spec.
454    pub fn load(spec: &ModuleResourceSpec, details: impl Into<String>) -> Self {
455        Self::Load {
456            key: spec.key.clone(),
457            path: spec.locale_relative_path.clone(),
458            required: spec.required,
459            details: details.into(),
460        }
461    }
462
463    /// Returns the key associated with this failure.
464    pub fn key(&self) -> &ResourceKey {
465        match self {
466            Self::Missing { key, .. }
467            | Self::InvalidUtf8 { key, .. }
468            | Self::Parse { key, .. }
469            | Self::Load { key, .. } => key,
470        }
471    }
472
473    /// Returns true when this failure affects required readiness.
474    pub fn is_required(&self) -> bool {
475        match self {
476            Self::Missing { required, .. }
477            | Self::InvalidUtf8 { required, .. }
478            | Self::Parse { required, .. }
479            | Self::Load { required, .. } => *required,
480        }
481    }
482}
483
484impl fmt::Display for ResourceLoadError {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        match self {
487            Self::Missing {
488                key,
489                path,
490                required,
491            } => write!(
492                f,
493                "missing {} resource '{}' at '{}'",
494                if *required { "required" } else { "optional" },
495                key,
496                path
497            ),
498            Self::InvalidUtf8 {
499                key,
500                path,
501                required,
502                details,
503            } => write!(
504                f,
505                "invalid UTF-8 in {} resource '{}' at '{}': {}",
506                if *required { "required" } else { "optional" },
507                key,
508                path,
509                details
510            ),
511            Self::Parse {
512                key,
513                path,
514                required,
515                details,
516            } => write!(
517                f,
518                "failed to parse {} resource '{}' at '{}': {}",
519                if *required { "required" } else { "optional" },
520                key,
521                path,
522                details
523            ),
524            Self::Load {
525                key,
526                path,
527                required,
528                details,
529            } => write!(
530                f,
531                "failed to load {} resource '{}' at '{}': {}",
532                if *required { "required" } else { "optional" },
533                key,
534                path,
535                details
536            ),
537        }
538    }
539}
540
541impl std::error::Error for ResourceLoadError {}
542
543/// Parses UTF-8 bytes into a `FluentResource` using the shared load contract.
544pub fn parse_fluent_resource_bytes(
545    spec: &ModuleResourceSpec,
546    bytes: &[u8],
547) -> Result<Arc<FluentResource>, ResourceLoadError> {
548    let content =
549        String::from_utf8(bytes.to_vec()).map_err(|e| ResourceLoadError::InvalidUtf8 {
550            key: spec.key.clone(),
551            path: spec.locale_relative_path.clone(),
552            required: spec.required,
553            details: e.to_string(),
554        })?;
555
556    parse_fluent_resource_content(spec, content)
557}
558
559/// Parses Fluent source text into a `FluentResource` using the shared load contract.
560pub fn parse_fluent_resource_content(
561    spec: &ModuleResourceSpec,
562    content: String,
563) -> Result<Arc<FluentResource>, ResourceLoadError> {
564    FluentResource::try_new(content)
565        .map(Arc::new)
566        .map_err(|(_, errs)| ResourceLoadError::Parse {
567            key: spec.key.clone(),
568            path: spec.locale_relative_path.clone(),
569            required: spec.required,
570            details: format!("{errs:?}"),
571        })
572}
573
574/// Common discovery contract for managers.
575///
576/// Any backend can iterate this inventory to discover registered modules.
577pub trait I18nModuleDescriptor: Send + Sync {
578    /// Returns static metadata for this module.
579    fn data(&self) -> &'static ModuleData;
580}
581
582/// A simple descriptor wrapper for metadata-only registrations.
583///
584/// This is used by asset-driven managers (e.g., Bevy) where runtime localization
585/// is handled by the host runtime rather than by `Localizer`.
586pub struct StaticModuleDescriptor {
587    data: &'static ModuleData,
588}
589
590impl StaticModuleDescriptor {
591    /// Creates a new metadata-only descriptor.
592    pub const fn new(data: &'static ModuleData) -> Self {
593        Self { data }
594    }
595}
596
597impl I18nModuleDescriptor for StaticModuleDescriptor {
598    fn data(&self) -> &'static ModuleData {
599        self.data
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use std::collections::HashSet;
607    use unic_langid::langid;
608
609    static SUPPORTED: &[LanguageIdentifier] = &[langid!("en-US"), langid!("fr")];
610    static NAMESPACES: &[&str] = &["ui", "errors"];
611    static DATA: ModuleData = ModuleData {
612        name: "test-module",
613        domain: "test-domain",
614        supported_languages: SUPPORTED,
615        namespaces: NAMESPACES,
616    };
617
618    #[test]
619    fn static_descriptor_new_and_data_round_trip() {
620        let module = StaticModuleDescriptor::new(&DATA);
621        let data = module.data();
622
623        assert_eq!(data.name, "test-module");
624        assert_eq!(data.domain, "test-domain");
625        assert_eq!(data.supported_languages, SUPPORTED);
626        assert_eq!(data.namespaces, NAMESPACES);
627    }
628
629    #[test]
630    fn resource_key_helpers_return_expected_shapes() {
631        let key = ResourceKey::new("app/ui");
632        assert_eq!(key.as_str(), "app/ui");
633        assert_eq!(key.domain(), "app");
634        assert_eq!(key.to_string(), "app/ui");
635    }
636
637    #[test]
638    fn resource_plan_without_namespaces_requires_base_file() {
639        let plan = resource_plan_for("app", &[]);
640        assert_eq!(
641            plan,
642            vec![ModuleResourceSpec {
643                key: ResourceKey::new("app"),
644                locale_relative_path: "app.ftl".to_string(),
645                required: true
646            }]
647        );
648    }
649
650    #[test]
651    fn resource_plan_with_namespaces_requires_namespace_files() {
652        let plan = resource_plan_for("app", &["ui", "errors"]);
653        assert_eq!(
654            plan,
655            vec![
656                ModuleResourceSpec {
657                    key: ResourceKey::new("app"),
658                    locale_relative_path: "app.ftl".to_string(),
659                    required: false
660                },
661                ModuleResourceSpec {
662                    key: ResourceKey::new("app/ui"),
663                    locale_relative_path: "app/ui.ftl".to_string(),
664                    required: true
665                },
666                ModuleResourceSpec {
667                    key: ResourceKey::new("app/errors"),
668                    locale_relative_path: "app/errors.ftl".to_string(),
669                    required: true
670                }
671            ]
672        );
673        assert_eq!(plan[1].locale_path(&langid!("en-US")), "en-US/app/ui.ftl");
674    }
675
676    #[test]
677    fn resource_plan_deduplicates_duplicate_namespaces() {
678        let plan = resource_plan_for("app", &["ui", "ui"]);
679        assert_eq!(plan.len(), 2);
680        assert_eq!(plan[1].key, ResourceKey::new("app/ui"));
681    }
682
683    #[test]
684    fn locale_is_ready_requires_all_required_keys() {
685        let plan = resource_plan_for("app", &["ui", "errors"]);
686        let required = required_resource_keys_from_plan(&plan);
687        let optional = optional_resource_keys_from_plan(&plan);
688
689        assert_eq!(optional, HashSet::from([ResourceKey::new("app")]));
690
691        let ready_loaded =
692            HashSet::from([ResourceKey::new("app/ui"), ResourceKey::new("app/errors")]);
693        assert!(locale_is_ready(&required, &ready_loaded));
694
695        let missing_required = HashSet::from([ResourceKey::new("app/ui")]);
696        assert!(!locale_is_ready(&required, &missing_required));
697    }
698
699    #[test]
700    fn locale_load_report_tracks_errors_and_readiness() {
701        let plan = resource_plan_for("app", &["ui"]);
702        let mut report = LocaleLoadReport::from_plan(&plan);
703
704        report.mark_loaded(ResourceKey::new("app/ui"));
705        report.record_error(ResourceLoadError::load(&plan[0], "file watcher error"));
706
707        assert!(report.is_ready());
708        assert_eq!(
709            report.required_keys(),
710            &HashSet::from([ResourceKey::new("app/ui")])
711        );
712        assert_eq!(
713            report.optional_keys(),
714            &HashSet::from([ResourceKey::new("app")])
715        );
716        assert!(report.loaded_keys().contains(&ResourceKey::new("app/ui")));
717        assert_eq!(report.missing_required_keys(), HashSet::new());
718    }
719
720    #[test]
721    fn validate_module_registry_rejects_duplicates_and_invalid_namespaces() {
722        static DUP_LANGUAGE: &[LanguageIdentifier] = &[langid!("en"), langid!("en")];
723        static INVALID_NAMESPACES: &[&str] = &["ui", "ui", "", "errors.ftl", "bad/path"];
724        static BAD_DATA: ModuleData = ModuleData {
725            name: "test-module",
726            domain: "test-domain",
727            supported_languages: DUP_LANGUAGE,
728            namespaces: INVALID_NAMESPACES,
729        };
730        static DUP_DOMAIN: ModuleData = ModuleData {
731            name: "other-module",
732            domain: "test-domain",
733            supported_languages: SUPPORTED,
734            namespaces: &[],
735        };
736
737        let errs = validate_module_registry([&DATA, &BAD_DATA, &DUP_DOMAIN])
738            .expect_err("validation should fail");
739        assert!(errs.iter().any(|err| matches!(
740            err,
741            ModuleRegistryError::DuplicateModuleName { name } if name == "test-module"
742        )));
743        assert!(errs.iter().any(|err| matches!(
744            err,
745            ModuleRegistryError::DuplicateDomain { domain } if domain == "test-domain"
746        )));
747        assert!(errs.iter().any(|err| matches!(
748            err,
749            ModuleRegistryError::DuplicateSupportedLanguage { module, .. } if module == "test-module"
750        )));
751        assert!(errs.iter().any(|err| matches!(
752            err,
753            ModuleRegistryError::DuplicateNamespace { module, namespace } if module == "test-module" && namespace == "ui"
754        )));
755    }
756
757    #[test]
758    fn module_data_resource_plan_delegates_to_shared_builder() {
759        let plan = DATA.resource_plan();
760        let direct = resource_plan_for(DATA.domain, DATA.namespaces);
761        assert_eq!(plan, direct);
762    }
763
764    #[test]
765    fn parse_fluent_resource_content_reports_parse_errors() {
766        let spec = ModuleResourceSpec {
767            key: ResourceKey::new("app/ui"),
768            locale_relative_path: "app/ui.ftl".to_string(),
769            required: true,
770        };
771
772        let err = parse_fluent_resource_content(&spec, "broken = {".to_string())
773            .expect_err("invalid fluent should fail");
774        assert!(matches!(
775            err,
776            ResourceLoadError::Parse { required: true, .. }
777        ));
778    }
779
780    #[test]
781    fn parse_fluent_resource_bytes_reports_utf8_errors() {
782        let spec = ModuleResourceSpec {
783            key: ResourceKey::new("app/ui"),
784            locale_relative_path: "app/ui.ftl".to_string(),
785            required: false,
786        };
787
788        let err =
789            parse_fluent_resource_bytes(&spec, &[0xFF, 0xFE]).expect_err("invalid utf-8 bytes");
790        assert!(matches!(
791            err,
792            ResourceLoadError::InvalidUtf8 {
793                required: false,
794                ..
795            }
796        ));
797    }
798}