Skip to main content

es_fluent_shared/
registry.rs

1//! This module provides types for representing FTL variants and type information.
2
3use crate::fluent::{
4    FluentArgumentName, FluentDomain, FluentEntryId, FluentIdentifierError, FluentMessageId,
5    FluentVariantKey,
6};
7use crate::meta::TypeKind;
8pub use crate::namespace::{NamespacePathError, NamespaceRule, ResolvedNamespace};
9use crate::source::{SourceFile, SourceLine, SourceLocation};
10use std::borrow::Borrow;
11use std::hash::{Hash, Hasher};
12use std::path::Path;
13
14/// Static Fluent domain emitted by derive macros.
15#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
16#[as_ref(str)]
17pub struct StaticFluentDomain(&'static str);
18
19impl StaticFluentDomain {
20    /// Creates a static domain from a caller-validated value.
21    ///
22    /// Derive macros emit this only after validating the domain during macro
23    /// expansion. Manual callers should prefer [`Self::try_new`].
24    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
25        Self(value)
26    }
27
28    /// Validates and creates a static domain.
29    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
30        FluentDomain::try_new(value)?;
31        Ok(Self(value))
32    }
33
34    /// Validates and creates the default domain derived from `CARGO_PKG_NAME`.
35    ///
36    /// Generated code uses this for implicit current-package domains because
37    /// the package name is only known in the consuming crate.
38    #[allow(
39        clippy::panic,
40        clippy::unwrap_used,
41        reason = "invalid package names should fail immediately when generated localization code is used"
42    )]
43    pub fn from_package_name(value: &'static str) -> Self {
44        Self::try_new(value).unwrap_or_else(|error| {
45            panic!("CARGO_PKG_NAME '{value}' is not a valid Fluent domain: {error}")
46        })
47    }
48
49    pub fn as_str(self) -> &'static str {
50        self.0
51    }
52
53    pub fn domain_name(self) -> FluentDomain {
54        FluentDomain::from_valid_static(self.0)
55    }
56}
57
58impl Borrow<str> for StaticFluentDomain {
59    fn borrow(&self) -> &str {
60        self.0
61    }
62}
63
64impl PartialEq<&str> for StaticFluentDomain {
65    fn eq(&self, other: &&str) -> bool {
66        self.0 == *other
67    }
68}
69
70/// Static Fluent message identifier emitted by derive macros.
71#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
72#[as_ref(str)]
73pub struct StaticFluentEntryId(&'static str);
74
75impl StaticFluentEntryId {
76    /// Creates a static message id from a caller-validated value.
77    ///
78    /// Derive macros emit this only after validating the message id during macro
79    /// expansion. Manual callers should prefer [`Self::try_new`].
80    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
81        Self(value)
82    }
83
84    /// Validates and creates a static message id.
85    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
86        FluentMessageId::try_new(value)?;
87        Ok(Self(value))
88    }
89
90    pub fn as_str(self) -> &'static str {
91        self.0
92    }
93
94    pub fn message_id(self) -> FluentMessageId {
95        FluentMessageId::from_valid_static(self.0)
96    }
97
98    pub fn entry_id(self) -> FluentEntryId {
99        FluentEntryId::from_valid_static(self.0)
100    }
101}
102
103impl Borrow<str> for StaticFluentEntryId {
104    fn borrow(&self) -> &str {
105        self.0
106    }
107}
108
109impl PartialEq<&str> for StaticFluentEntryId {
110    fn eq(&self, other: &&str) -> bool {
111        self.0 == *other
112    }
113}
114
115/// A fully scoped Fluent message key emitted by derive macros.
116///
117/// `owner` identifies the crate whose `i18n.toml` defines `domain`. Domains are
118/// package-local, so two crates may both define a domain such as `ui` without
119/// sharing runtime lookup scope. Generated fallback text is lookup metadata and
120/// does not participate in equality or hashing.
121#[derive(Clone, Copy, Debug)]
122pub struct StaticFluentMessageKey {
123    owner: StaticFluentDomain,
124    domain: StaticFluentDomain,
125    id: StaticFluentEntryId,
126    fallback: Option<&'static str>,
127}
128
129impl StaticFluentMessageKey {
130    /// Creates a fully scoped key from validated static parts.
131    pub const fn new(
132        owner: StaticFluentDomain,
133        domain: StaticFluentDomain,
134        id: StaticFluentEntryId,
135    ) -> Self {
136        Self {
137            owner,
138            domain,
139            id,
140            fallback: None,
141        }
142    }
143
144    /// Creates a fully scoped key with a generated fallback string.
145    pub const fn with_fallback(
146        owner: StaticFluentDomain,
147        domain: StaticFluentDomain,
148        id: StaticFluentEntryId,
149        fallback: &'static str,
150    ) -> Self {
151        Self {
152            owner,
153            domain,
154            id,
155            fallback: Some(fallback),
156        }
157    }
158
159    /// Returns the crate that owns this key's domain definition.
160    pub const fn owner(self) -> StaticFluentDomain {
161        self.owner
162    }
163
164    /// Returns the package-local Fluent domain.
165    pub const fn domain(self) -> StaticFluentDomain {
166        self.domain
167    }
168
169    /// Returns the Fluent message identifier.
170    pub const fn id(self) -> StaticFluentEntryId {
171        self.id
172    }
173
174    /// Returns the generated snake_case fallback string when one is available.
175    pub const fn fallback(self) -> Option<&'static str> {
176        self.fallback
177    }
178}
179
180impl PartialEq for StaticFluentMessageKey {
181    fn eq(&self, other: &Self) -> bool {
182        self.owner == other.owner && self.domain == other.domain && self.id == other.id
183    }
184}
185
186impl Eq for StaticFluentMessageKey {}
187
188impl Hash for StaticFluentMessageKey {
189    fn hash<H: Hasher>(&self, state: &mut H) {
190        self.owner.hash(state);
191        self.domain.hash(state);
192        self.id.hash(state);
193    }
194}
195
196/// Static Fluent argument name emitted by derive macros.
197#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
198#[as_ref(str)]
199pub struct StaticFluentArgumentName(&'static str);
200
201impl StaticFluentArgumentName {
202    /// Creates a static argument name from a caller-validated value.
203    ///
204    /// Derive macros emit this only after validating the name during macro
205    /// expansion. Manual callers should prefer [`Self::try_new`].
206    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
207        Self(value)
208    }
209
210    /// Validates and creates a static argument name.
211    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
212        FluentArgumentName::try_new(value)?;
213        Ok(Self(value))
214    }
215
216    pub fn as_str(self) -> &'static str {
217        self.0
218    }
219
220    pub fn argument_name(self) -> FluentArgumentName {
221        FluentArgumentName::from_valid_static(self.0)
222    }
223}
224
225impl Borrow<str> for StaticFluentArgumentName {
226    fn borrow(&self) -> &str {
227        self.0
228    }
229}
230
231impl PartialEq<&str> for StaticFluentArgumentName {
232    fn eq(&self, other: &&str) -> bool {
233        self.0 == *other
234    }
235}
236
237/// Static Fluent select variant key emitted by derive macros.
238#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
239#[as_ref(str)]
240pub struct StaticFluentVariantKey(&'static str);
241
242impl StaticFluentVariantKey {
243    /// Creates a static variant key from a caller-validated value.
244    ///
245    /// Derive macros emit this only after validating the key during macro
246    /// expansion. Manual callers should prefer [`Self::try_new`].
247    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
248        Self(value)
249    }
250
251    /// Validates and creates a static select variant key.
252    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
253        FluentVariantKey::try_new(value)?;
254        Ok(Self(value))
255    }
256
257    pub fn as_str(self) -> &'static str {
258        self.0
259    }
260
261    pub fn variant_key(self) -> FluentVariantKey {
262        FluentVariantKey::from_valid_static(self.0)
263    }
264}
265
266impl Borrow<str> for StaticFluentVariantKey {
267    fn borrow(&self) -> &str {
268        self.0
269    }
270}
271
272impl PartialEq<&str> for StaticFluentVariantKey {
273    fn eq(&self, other: &&str) -> bool {
274        self.0 == *other
275    }
276}
277
278/// A variant representing a single FTL key entry.
279#[derive(Clone, Debug, Eq, Hash, PartialEq)]
280pub struct FtlVariant {
281    name: &'static str,
282    ftl_key: StaticFluentEntryId,
283    args: &'static [StaticFluentArgumentName],
284    /// The module path from `module_path!()`.
285    module_path: &'static str,
286    /// The line number from `line!()` macro.
287    line: u32,
288}
289
290impl FtlVariant {
291    /// Creates static variant metadata from validated static parts.
292    pub const fn new(
293        name: &'static str,
294        ftl_key: StaticFluentEntryId,
295        args: &'static [StaticFluentArgumentName],
296        module_path: &'static str,
297        line: u32,
298    ) -> Self {
299        Self {
300            name,
301            ftl_key,
302            args,
303            module_path,
304            line,
305        }
306    }
307
308    pub fn name(&self) -> &'static str {
309        self.name
310    }
311
312    pub fn args(&self) -> &'static [StaticFluentArgumentName] {
313        self.args
314    }
315
316    pub fn module_path(&self) -> &'static str {
317        self.module_path
318    }
319
320    pub fn entry_id(&self) -> FluentEntryId {
321        self.ftl_key.entry_id()
322    }
323
324    /// Returns the validated Fluent message id for this variant.
325    pub fn message_id(&self) -> FluentMessageId {
326        self.ftl_key.message_id()
327    }
328
329    /// Returns the validated Fluent argument names for this variant.
330    pub fn argument_names(&self) -> Vec<FluentArgumentName> {
331        self.args.iter().map(|arg| arg.argument_name()).collect()
332    }
333
334    /// Returns typed source line metadata for this variant.
335    pub fn source_line(&self) -> SourceLine {
336        SourceLine::new(self.line)
337    }
338}
339
340/// Package and package-local domain scope for generated FTL inventory.
341#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
342pub struct FtlScope {
343    source_package: &'static str,
344    domain: Option<StaticFluentDomain>,
345}
346
347impl FtlScope {
348    /// Creates an inventory scope from a Cargo package and optional explicit domain.
349    pub const fn new(source_package: &'static str, domain: Option<StaticFluentDomain>) -> Self {
350        Self {
351            source_package,
352            domain,
353        }
354    }
355
356    /// Returns the Cargo package that declares the inventory.
357    pub const fn source_package(self) -> &'static str {
358        self.source_package
359    }
360
361    /// Returns the optional explicit package-local output domain.
362    pub const fn domain(self) -> Option<StaticFluentDomain> {
363        self.domain
364    }
365}
366
367/// Type information for FTL registration, used by derive macros and the CLI.
368#[derive(Clone, Debug, Eq, Hash, PartialEq)]
369pub struct FtlTypeInfo {
370    type_kind: TypeKind,
371    type_name: &'static str,
372    variants: &'static [FtlVariant],
373    /// The Cargo package and optional explicit output domain.
374    scope: FtlScope,
375    /// The file path where this type is defined (from `file!()` macro).
376    file_path: &'static str,
377    /// The module path where this type is defined (from `module_path!()` macro).
378    module_path: &'static str,
379    /// Optional namespace for FTL file output. If Some, the type will be written to
380    /// `{lang}/{domain}/{namespace}.ftl` instead of `{lang}/{domain}.ftl`.
381    namespace: Option<NamespaceRule>,
382}
383
384impl AsRef<FtlTypeInfo> for FtlTypeInfo {
385    fn as_ref(&self) -> &FtlTypeInfo {
386        self
387    }
388}
389
390impl FtlTypeInfo {
391    /// Creates static type metadata from validated static parts.
392    pub const fn new(
393        type_kind: TypeKind,
394        type_name: &'static str,
395        variants: &'static [FtlVariant],
396        scope: FtlScope,
397        file_path: &'static str,
398        module_path: &'static str,
399        namespace: Option<NamespaceRule>,
400    ) -> Self {
401        Self {
402            type_kind,
403            type_name,
404            variants,
405            scope,
406            file_path,
407            module_path,
408            namespace,
409        }
410    }
411
412    pub fn type_kind(&self) -> &TypeKind {
413        &self.type_kind
414    }
415
416    pub fn type_name(&self) -> &'static str {
417        self.type_name
418    }
419
420    pub fn variants(&self) -> &'static [FtlVariant] {
421        self.variants
422    }
423
424    pub fn source_package(&self) -> &'static str {
425        self.scope.source_package()
426    }
427
428    pub fn file_path(&self) -> &'static str {
429        self.file_path
430    }
431
432    pub fn module_path(&self) -> &'static str {
433        self.module_path
434    }
435
436    pub fn domain(&self) -> Option<StaticFluentDomain> {
437        self.scope.domain()
438    }
439
440    pub fn namespace(&self) -> Option<&NamespaceRule> {
441        self.namespace.as_ref()
442    }
443
444    /// Returns typed source file metadata when this type has a recorded file path.
445    pub fn source_file(&self) -> Option<SourceFile> {
446        SourceFile::new(self.file_path)
447    }
448
449    /// Returns typed source location metadata for the given variant.
450    pub fn source_location_for(&self, variant: &FtlVariant) -> Option<SourceLocation> {
451        SourceLocation::new(self.file_path, variant.line)
452    }
453
454    /// Returns a stable human-readable source description for diagnostics.
455    pub fn source_description_for(&self, variant: &FtlVariant) -> String {
456        let item = format!("type '{}' variant '{}'", self.type_name, variant.name);
457        match self.source_location_for(variant) {
458            Some(location) => format!(
459                "{item} at {}:{}",
460                location.file().as_str(),
461                location.line().get()
462            ),
463            None => format!("{item} at line {}", variant.source_line().get()),
464        }
465    }
466
467    /// Resolve the namespace for this type, if configured.
468    pub fn resolved_namespace(&self, manifest_dir: &Path) -> Option<String> {
469        self.namespace
470            .as_ref()
471            .map(|rule| rule.resolve(self.file_path, Some(manifest_dir)))
472    }
473
474    /// Resolve and validate the namespace for this type, if configured.
475    pub fn try_resolved_namespace(
476        &self,
477        manifest_dir: &Path,
478    ) -> Result<Option<ResolvedNamespace>, NamespacePathError> {
479        self.namespace
480            .as_ref()
481            .map(|rule| rule.try_resolve(self.file_path, Some(manifest_dir)))
482            .transpose()
483    }
484}
485
486/// Constructors used by generated macro output.
487///
488/// These functions keep generated metadata on a narrow construction surface
489/// while the public structs expose read-only accessors.
490#[doc(hidden)]
491pub mod __macro {
492    use super::{
493        FtlScope, FtlTypeInfo, FtlVariant, NamespaceRule, ResolvedNamespace,
494        StaticFluentArgumentName, StaticFluentDomain, StaticFluentEntryId, StaticFluentMessageKey,
495        StaticFluentVariantKey,
496    };
497    use crate::meta::TypeKind;
498
499    pub const fn static_domain(value: &'static str) -> StaticFluentDomain {
500        StaticFluentDomain::new_unchecked(value)
501    }
502
503    pub const fn static_entry_id(value: &'static str) -> StaticFluentEntryId {
504        StaticFluentEntryId::new_unchecked(value)
505    }
506
507    pub const fn static_message_key(
508        owner: &'static str,
509        domain: StaticFluentDomain,
510        id: StaticFluentEntryId,
511    ) -> StaticFluentMessageKey {
512        StaticFluentMessageKey::new(StaticFluentDomain::new_unchecked(owner), domain, id)
513    }
514
515    pub const fn static_message_key_with_fallback(
516        owner: &'static str,
517        domain: StaticFluentDomain,
518        id: StaticFluentEntryId,
519        fallback: &'static str,
520    ) -> StaticFluentMessageKey {
521        StaticFluentMessageKey::with_fallback(
522            StaticFluentDomain::new_unchecked(owner),
523            domain,
524            id,
525            fallback,
526        )
527    }
528
529    pub const fn static_argument_name(value: &'static str) -> StaticFluentArgumentName {
530        StaticFluentArgumentName::new_unchecked(value)
531    }
532
533    pub const fn static_variant_key(value: &'static str) -> StaticFluentVariantKey {
534        StaticFluentVariantKey::new_unchecked(value)
535    }
536
537    pub const fn namespace_literal(value: &'static str) -> NamespaceRule {
538        NamespaceRule::Literal(ResolvedNamespace::from_static_unchecked(value))
539    }
540
541    pub const fn ftl_variant(
542        name: &'static str,
543        ftl_key: StaticFluentEntryId,
544        args: &'static [StaticFluentArgumentName],
545        module_path: &'static str,
546        line: u32,
547    ) -> FtlVariant {
548        FtlVariant::new(name, ftl_key, args, module_path, line)
549    }
550
551    pub const fn ftl_type_info(
552        type_kind: TypeKind,
553        type_name: &'static str,
554        variants: &'static [FtlVariant],
555        scope: FtlScope,
556        file_path: &'static str,
557        module_path: &'static str,
558        namespace: Option<NamespaceRule>,
559    ) -> FtlTypeInfo {
560        FtlTypeInfo::new(
561            type_kind,
562            type_name,
563            variants,
564            scope,
565            file_path,
566            module_path,
567            namespace,
568        )
569    }
570
571    pub const fn ftl_scope(
572        source_package: &'static str,
573        domain: Option<StaticFluentDomain>,
574    ) -> FtlScope {
575        FtlScope::new(source_package, domain)
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::{
582        FtlScope, FtlTypeInfo, NamespacePathError, NamespaceRule, StaticFluentArgumentName,
583        StaticFluentDomain, StaticFluentEntryId, StaticFluentMessageKey, StaticFluentVariantKey,
584    };
585    use crate::meta::TypeKind;
586    use crate::registry::FtlVariant;
587    use std::collections::HashMap;
588    use std::path::PathBuf;
589
590    fn test_manifest_dir() -> PathBuf {
591        if cfg!(windows) {
592            PathBuf::from(r"C:\repo\app")
593        } else {
594            PathBuf::from("/repo/app")
595        }
596    }
597
598    #[test]
599    fn static_variant_key_validates_and_exposes_typed_value() {
600        let key = StaticFluentVariantKey::try_new("very-high").expect("valid variant key");
601
602        assert_eq!(key.as_str(), "very-high");
603        assert_eq!(key.variant_key().as_str(), "very-high");
604        assert!(StaticFluentVariantKey::try_new("not valid").is_err());
605    }
606
607    #[test]
608    fn fallback_metadata_does_not_change_static_message_key_identity() {
609        let owner = StaticFluentDomain::new_unchecked("demo");
610        let domain = StaticFluentDomain::new_unchecked("ui");
611        let id = StaticFluentEntryId::new_unchecked("save-button");
612        let plain = StaticFluentMessageKey::new(owner, domain, id);
613        let fallback = StaticFluentMessageKey::with_fallback(owner, domain, id, "save_button");
614        let messages = HashMap::from([(plain, "Save")]);
615
616        assert_eq!(plain, fallback);
617        assert_eq!(fallback.fallback(), Some("save_button"));
618        assert_eq!(messages.get(&fallback), Some(&"Save"));
619    }
620
621    #[test]
622    fn file_namespace_uses_stem() {
623        let manifest_dir = test_manifest_dir();
624        let file_path = manifest_dir.join("src").join("lib.rs");
625        let namespace = NamespaceRule::File.resolve(
626            file_path.to_str().expect("utf-8 test path"),
627            Some(&manifest_dir),
628        );
629        assert_eq!(namespace, "lib");
630    }
631
632    #[test]
633    fn file_relative_strips_manifest_and_src() {
634        let manifest_dir = test_manifest_dir();
635        let file_path = manifest_dir.join("src").join("ui").join("button.rs");
636        let namespace = NamespaceRule::FileRelative.resolve(
637            file_path.to_str().expect("utf-8 test path"),
638            Some(&manifest_dir),
639        );
640        assert_eq!(namespace, "ui/button");
641    }
642
643    #[test]
644    fn file_relative_falls_back_to_stem_outside_manifest() {
645        let manifest_dir = test_manifest_dir();
646        let file_path = if cfg!(windows) {
647            PathBuf::from(r"C:\other\src\lib.rs")
648        } else {
649            PathBuf::from("/other/src/lib.rs")
650        };
651        let namespace = NamespaceRule::FileRelative.resolve(
652            file_path.to_str().expect("utf-8 test path"),
653            Some(&manifest_dir),
654        );
655        assert_eq!(namespace, "lib");
656    }
657
658    #[test]
659    fn folder_namespace_uses_parent_folder_name() {
660        let manifest_dir = test_manifest_dir();
661        let file_path = manifest_dir
662            .join("src")
663            .join("ui")
664            .join("forms")
665            .join("button.rs");
666        let namespace = NamespaceRule::Folder.resolve(
667            file_path.to_str().expect("utf-8 test path"),
668            Some(&manifest_dir),
669        );
670        assert_eq!(namespace, "forms");
671    }
672
673    #[test]
674    fn folder_relative_strips_manifest_and_src() {
675        let manifest_dir = test_manifest_dir();
676        let file_path = manifest_dir
677            .join("src")
678            .join("ui")
679            .join("forms")
680            .join("button.rs");
681        let namespace = NamespaceRule::FolderRelative.resolve(
682            file_path.to_str().expect("utf-8 test path"),
683            Some(&manifest_dir),
684        );
685        assert_eq!(namespace, "ui/forms");
686    }
687
688    #[test]
689    fn folder_relative_keeps_src_for_root_module() {
690        let manifest_dir = test_manifest_dir();
691        let file_path = manifest_dir.join("src").join("lib.rs");
692        let namespace = NamespaceRule::FolderRelative.resolve(
693            file_path.to_str().expect("utf-8 test path"),
694            Some(&manifest_dir),
695        );
696        assert_eq!(namespace, "src");
697    }
698
699    #[test]
700    fn folder_relative_falls_back_to_parent_outside_manifest() {
701        let manifest_dir = test_manifest_dir();
702        let file_path = if cfg!(windows) {
703            PathBuf::from(r"C:\other\src\lib.rs")
704        } else {
705            PathBuf::from("/other/src/lib.rs")
706        };
707        let namespace = NamespaceRule::FolderRelative.resolve(
708            file_path.to_str().expect("utf-8 test path"),
709            Some(&manifest_dir),
710        );
711        assert_eq!(namespace, "src");
712    }
713
714    #[test]
715    fn ftl_type_info_try_resolved_namespace_returns_validated_namespace() {
716        let manifest_dir = test_manifest_dir();
717        let info = FtlTypeInfo::new(
718            TypeKind::Enum,
719            "ButtonCopy",
720            &[],
721            FtlScope::new("demo", None),
722            "src/ui/button.rs",
723            "demo",
724            Some(NamespaceRule::FileRelative),
725        );
726
727        let namespace = info
728            .try_resolved_namespace(&manifest_dir)
729            .expect("namespace should resolve")
730            .expect("namespace configured");
731
732        assert_eq!(namespace.as_str(), "ui/button");
733        assert_eq!(
734            namespace.try_resource_key("demo").unwrap().as_str(),
735            "demo/ui/button"
736        );
737    }
738
739    #[test]
740    fn ftl_type_info_try_resolved_namespace_rejects_invalid_literal() {
741        let manifest_dir = test_manifest_dir();
742        let info = FtlTypeInfo::new(
743            TypeKind::Enum,
744            "EscapingCopy",
745            &[],
746            FtlScope::new("demo", None),
747            "src/lib.rs",
748            "demo",
749            Some(super::__macro::namespace_literal("../escape")),
750        );
751
752        let err = info
753            .try_resolved_namespace(&manifest_dir)
754            .expect_err("invalid literal should fail");
755
756        assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
757    }
758
759    #[test]
760    fn ftl_type_info_exposes_typed_source_metadata() {
761        static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
762            "Ready",
763            StaticFluentEntryId::new_unchecked("status-Ready"),
764            &[],
765            "demo",
766            42,
767        )];
768        let info = FtlTypeInfo::new(
769            TypeKind::Enum,
770            "Status",
771            VARIANTS,
772            FtlScope::new("demo", None),
773            "src/status.rs",
774            "demo",
775            None,
776        );
777
778        assert_eq!(info.source_package(), "demo");
779        assert_eq!(info.source_file().unwrap().as_str(), "src/status.rs");
780        assert_eq!(VARIANTS[0].entry_id().as_str(), "status-Ready");
781        assert_eq!(VARIANTS[0].message_id().as_str(), "status-Ready");
782        assert_eq!(VARIANTS[0].argument_names(), Vec::new());
783        assert_eq!(VARIANTS[0].source_line().get(), 42);
784
785        let location = info.source_location_for(&VARIANTS[0]).unwrap();
786        assert_eq!(location.file().as_str(), "src/status.rs");
787        assert_eq!(location.line().get(), 42);
788    }
789
790    #[test]
791    fn empty_type_file_path_has_no_typed_source_location() {
792        static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
793            "Ready",
794            StaticFluentEntryId::new_unchecked("status-Ready"),
795            &[],
796            "demo",
797            42,
798        )];
799        let info = FtlTypeInfo::new(
800            TypeKind::Enum,
801            "Status",
802            VARIANTS,
803            FtlScope::new("demo", None),
804            "",
805            "demo",
806            None,
807        );
808
809        assert!(info.source_file().is_none());
810        assert!(info.source_location_for(&VARIANTS[0]).is_none());
811    }
812
813    #[test]
814    fn static_fluent_wrappers_validate_manual_construction() {
815        assert_eq!(
816            StaticFluentEntryId::try_new("_invalid")
817                .unwrap_err()
818                .to_string(),
819            "Fluent message id must start with an ASCII letter"
820        );
821        assert_eq!(
822            StaticFluentEntryId::try_new("-shared-term")
823                .unwrap_err()
824                .to_string(),
825            "Fluent message id must start with an ASCII letter"
826        );
827        assert_eq!(
828            StaticFluentDomain::try_new("app-domain")
829                .expect("domain")
830                .domain_name()
831                .as_str(),
832            "app-domain"
833        );
834        assert_eq!(
835            StaticFluentDomain::try_new("app-domain")
836                .expect("domain")
837                .as_str(),
838            "app-domain"
839        );
840        assert_eq!(
841            StaticFluentDomain::from_package_name("package-domain").as_str(),
842            "package-domain"
843        );
844        assert_eq!(
845            StaticFluentArgumentName::try_new("not valid")
846                .unwrap_err()
847                .to_string(),
848            "Fluent argument name contains invalid character ' '; use ASCII letters, digits, '_' or '-'"
849        );
850    }
851}