Skip to main content

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    ///
107    /// # Errors
108    ///
109    /// Whatever [`ResRef::new`] reports: a name longer than sixteen bytes once
110    /// encoded, or one carrying a character Windows-1252 cannot hold. An
111    /// identifier can be built from a name that is not a legal resref, so this
112    /// is where that shows up.
113    pub fn resref(&self) -> Result<ResRef, crate::ResRefError> {
114        ResRef::new(&self.resname)
115    }
116
117    fn build_canonical_lower(resname: &str, restype: ResourceType) -> String {
118        if restype == ResourceType::Invalid {
119            resname.to_ascii_lowercase()
120        } else {
121            format!("{}.{}", resname.to_ascii_lowercase(), restype.extension())
122        }
123    }
124}
125
126impl PartialEq for ResourceIdentifier {
127    fn eq(&self, other: &Self) -> bool {
128        self.canonical_lower == other.canonical_lower
129    }
130}
131
132impl Eq for ResourceIdentifier {}
133
134impl Hash for ResourceIdentifier {
135    fn hash<H: Hasher>(&self, state: &mut H) {
136        self.canonical_lower.hash(state);
137    }
138}
139
140impl Display for ResourceIdentifier {
141    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
142        f.write_str(&self.canonical_lower)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn parses_basic_resource_path() {
152        let ident = ResourceIdentifier::from_path("foo/bar/P_Bastila.utc");
153        assert_eq!(ident.resname(), "P_Bastila");
154        assert_eq!(ident.restype(), ResourceType::Utc);
155    }
156
157    #[test]
158    fn equality_is_case_insensitive() {
159        let a = ResourceIdentifier::new("p_bastila", ResourceType::Utc);
160        let b = ResourceIdentifier::new("P_BASTILA", ResourceType::Utc);
161        assert_eq!(a, b);
162    }
163
164    #[test]
165    fn display_is_cached_and_canonical() {
166        let ident = ResourceIdentifier::new("P_Bastila", ResourceType::Utc);
167        assert_eq!(ident.to_string(), "p_bastila.utc");
168    }
169}