rakata_core/
resource_identifier.rs

1use std::fmt::{Display, Formatter};
2use std::hash::{Hash, Hasher};
3use std::path::Path;
4
5use crate::ResRef;
6
7use crate::resource_type::ResourceType;
8
9#[cfg(feature = "tracing")]
10macro_rules! trace_debug {
11    ($($arg:tt)*) => {
12        tracing::debug!($($arg)*);
13    };
14}
15
16#[cfg(not(feature = "tracing"))]
17macro_rules! trace_debug {
18    ($($arg:tt)*) => {};
19}
20
21/// Case-insensitive `(resname, restype)` identifier.
22///
23/// Equality and hashing are normalized to lowercase filename form so that
24/// identifiers compare the way KotOR resource lookups typically behave.
25#[derive(Debug, Clone)]
26pub struct ResourceIdentifier {
27    resname: String,
28    restype: ResourceType,
29    canonical_lower: String,
30}
31
32impl ResourceIdentifier {
33    /// Creates a new identifier from parts.
34    pub fn new(resname: impl Into<String>, restype: ResourceType) -> Self {
35        let resname = resname.into();
36        let canonical_lower = Self::build_canonical_lower(&resname, restype);
37        Self {
38            resname,
39            restype,
40            canonical_lower,
41        }
42    }
43
44    /// Parses an identifier from a path-like input.
45    ///
46    /// Parsing uses the final path component and maps its extension to
47    /// [`ResourceType`]. Unknown extensions map to [`ResourceType::Invalid`].
48    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(path)))]
49    pub fn from_path(path: impl AsRef<Path>) -> Self {
50        let path = path.as_ref();
51        let filename = path
52            .file_name()
53            .and_then(|s| s.to_str())
54            .unwrap_or_default();
55
56        if filename.is_empty() {
57            trace_debug!("resource identifier path had empty filename");
58            return Self::new("", ResourceType::Invalid);
59        }
60
61        if filename.ends_with('.') {
62            trace_debug!(
63                filename,
64                "resource identifier filename ended with trailing dot"
65            );
66            return Self::new(filename, ResourceType::Invalid);
67        }
68
69        if let Some((stem, ext)) = filename.rsplit_once('.') {
70            if stem.is_empty() {
71                // Keep full filename when it is only an extension-like token.
72                trace_debug!(
73                    filename,
74                    "resource identifier filename had empty stem before extension"
75                );
76                return Self::new(filename, ResourceType::Invalid);
77            }
78            let restype = ResourceType::from_extension(ext);
79            trace_debug!(filename, stem, ext, restype = ?restype, "resource identifier parsed");
80            return Self::new(stem, restype);
81        }
82
83        trace_debug!(
84            filename,
85            "resource identifier filename had no extension; defaulting to invalid type"
86        );
87        Self::new(filename, ResourceType::Invalid)
88    }
89
90    /// Returns the resource name without extension.
91    pub fn resname(&self) -> &str {
92        &self.resname
93    }
94
95    /// Returns the parsed resource type.
96    pub fn restype(&self) -> ResourceType {
97        self.restype
98    }
99
100    /// Returns `(resname, restype)` as borrowed values.
101    pub fn unpack(&self) -> (&str, ResourceType) {
102        (&self.resname, self.restype)
103    }
104
105    /// Converts the resource name into a validated [`ResRef`].
106    pub fn resref(&self) -> Result<ResRef, crate::ResRefError> {
107        ResRef::new(&self.resname)
108    }
109
110    fn build_canonical_lower(resname: &str, restype: ResourceType) -> String {
111        if restype == ResourceType::Invalid {
112            resname.to_ascii_lowercase()
113        } else {
114            format!("{}.{}", resname.to_ascii_lowercase(), restype.extension())
115        }
116    }
117}
118
119impl PartialEq for ResourceIdentifier {
120    fn eq(&self, other: &Self) -> bool {
121        self.canonical_lower == other.canonical_lower
122    }
123}
124
125impl Eq for ResourceIdentifier {}
126
127impl Hash for ResourceIdentifier {
128    fn hash<H: Hasher>(&self, state: &mut H) {
129        self.canonical_lower.hash(state);
130    }
131}
132
133impl Display for ResourceIdentifier {
134    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
135        f.write_str(&self.canonical_lower)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn parses_basic_resource_path() {
145        let ident = ResourceIdentifier::from_path("foo/bar/P_Bastila.utc");
146        assert_eq!(ident.resname(), "P_Bastila");
147        assert_eq!(ident.restype(), ResourceType::Utc);
148    }
149
150    #[test]
151    fn equality_is_case_insensitive() {
152        let a = ResourceIdentifier::new("p_bastila", ResourceType::Utc);
153        let b = ResourceIdentifier::new("P_BASTILA", ResourceType::Utc);
154        assert_eq!(a, b);
155    }
156
157    #[test]
158    fn display_is_cached_and_canonical() {
159        let ident = ResourceIdentifier::new("P_Bastila", ResourceType::Utc);
160        assert_eq!(ident.to_string(), "p_bastila.utc");
161    }
162}