ailloli_ui_core/upload.rs
1//! Pure upload/dropzone metadata and accept matching.
2
3use std::path::{Path, PathBuf};
4
5/// Metadata describing one file offered to an upload control.
6///
7/// The value does not grant filesystem access and does not read the file.
8///
9/// # Examples
10///
11/// ```
12/// use ailloli_ui_core::UploadFile;
13///
14/// let file = UploadFile::named("report.pdf");
15/// assert_eq!(file.name, "report.pdf");
16/// assert_eq!(file.size, None);
17/// ```
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UploadFile {
20 /// Local path when the producer is allowed to expose one; `None` otherwise.
21 pub path: Option<PathBuf>,
22 /// Display name used for extension matching; it may be empty.
23 pub name: String,
24 /// File size in bytes when known; `None` means unavailable, not zero bytes.
25 pub size: Option<u64>,
26 /// Unverified MIME type hint when known; matching is ASCII case-insensitive.
27 pub mime_hint: Option<String>,
28}
29
30/// Normalized extension and MIME patterns accepted by an upload control.
31///
32/// Supported forms are `.ext`, an exact MIME type such as `image/png`, and a
33/// top-level wildcard such as `image/*`. An empty pattern set accepts all files.
34///
35/// # Examples
36///
37/// ```
38/// use ailloli_ui_core::UploadAccept;
39///
40/// let accept = UploadAccept::new([".png", "image/*"]);
41/// assert_eq!(accept.patterns(), [".png", "image/*"]);
42/// ```
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct UploadAccept {
45 /// Ordered extension or MIME-style patterns accepted by the target.
46 patterns: Vec<String>,
47}
48
49impl UploadFile {
50 /// Creates pathless metadata with unknown size and MIME type.
51 ///
52 /// # Examples
53 ///
54 /// ```
55 /// use ailloli_ui_core::UploadFile;
56 ///
57 /// let file = UploadFile::named("photo.png");
58 /// assert!(file.path.is_none());
59 /// ```
60 pub fn named(name: impl Into<String>) -> Self {
61 Self {
62 path: None,
63 name: name.into(),
64 size: None,
65 mime_hint: None,
66 }
67 }
68
69 /// Creates metadata from a path without reading its target.
70 ///
71 /// [`Self::name`] is the final UTF-8 path component, or an empty string
72 /// when that component is absent or not valid UTF-8. Size and MIME type
73 /// remain unknown.
74 ///
75 /// # Examples
76 ///
77 /// ```
78 /// use std::path::PathBuf;
79 /// use ailloli_ui_core::UploadFile;
80 ///
81 /// let file = UploadFile::from_path(PathBuf::from("images/photo.png"));
82 /// assert_eq!(file.name, "photo.png");
83 /// ```
84 pub fn from_path(path: PathBuf) -> Self {
85 let name = path
86 .file_name()
87 .and_then(|name| name.to_str())
88 .unwrap_or_default()
89 .to_string();
90 Self {
91 path: Some(path),
92 name,
93 size: None,
94 mime_hint: None,
95 }
96 }
97}
98
99impl UploadAccept {
100 /// Normalizes and stores the supplied patterns in input order.
101 ///
102 /// Patterns are trimmed, converted to lowercase, and empty entries are
103 /// discarded. Duplicates and unsupported forms are preserved but do not
104 /// gain special matching behavior.
105 ///
106 /// # Examples
107 ///
108 /// ```
109 /// use ailloli_ui_core::UploadAccept;
110 ///
111 /// let accept = UploadAccept::new([" .PNG ", ""]);
112 /// assert_eq!(accept.patterns(), [".png"]);
113 /// ```
114 pub fn new(patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
115 Self {
116 patterns: patterns
117 .into_iter()
118 .map(|p| p.into().trim().to_ascii_lowercase())
119 .filter(|p| !p.is_empty())
120 .collect(),
121 }
122 }
123
124 /// Returns `true` when there are no effective restrictions.
125 ///
126 /// # Examples
127 ///
128 /// ```
129 /// use ailloli_ui_core::UploadAccept;
130 ///
131 /// assert!(UploadAccept::default().is_empty());
132 /// ```
133 pub fn is_empty(&self) -> bool {
134 self.patterns.is_empty()
135 }
136
137 /// Returns normalized patterns in their original relative order.
138 ///
139 /// # Examples
140 ///
141 /// ```
142 /// use ailloli_ui_core::UploadAccept;
143 ///
144 /// assert_eq!(UploadAccept::new([".PNG"]).patterns(), [".png"]);
145 /// ```
146 pub fn patterns(&self) -> &[String] {
147 &self.patterns
148 }
149
150 /// Returns whether at least one pattern matches `file`.
151 ///
152 /// Extension patterns use [`UploadFile::name`]; MIME patterns require a
153 /// [`UploadFile::mime_hint`]. With no patterns this returns `true`.
154 ///
155 /// # Examples
156 ///
157 /// ```
158 /// use ailloli_ui_core::{UploadAccept, UploadFile};
159 ///
160 /// let images = UploadAccept::new([".png", "image/*"]);
161 /// assert!(images.accepts(&UploadFile::named("avatar.PNG")));
162 /// assert!(!images.accepts(&UploadFile::named("notes.txt")));
163 /// ```
164 pub fn accepts(&self, file: &UploadFile) -> bool {
165 if self.patterns.is_empty() {
166 return true;
167 }
168 self.patterns.iter().any(|pattern| {
169 if let Some(ext) = pattern.strip_prefix('.') {
170 extension_matches(&file.name, ext)
171 } else if pattern.ends_with("/*") {
172 let prefix = pattern.trim_end_matches('*');
173 file.mime_hint
174 .as_deref()
175 .is_some_and(|mime| mime.to_ascii_lowercase().starts_with(prefix))
176 } else {
177 file.mime_hint
178 .as_deref()
179 .is_some_and(|mime| mime.eq_ignore_ascii_case(pattern))
180 }
181 })
182 }
183}
184
185/// Matches the final UTF-8 extension without its leading dot.
186fn extension_matches(name: &str, ext: &str) -> bool {
187 Path::new(name)
188 .extension()
189 .and_then(|ext| ext.to_str())
190 .is_some_and(|actual| actual.eq_ignore_ascii_case(ext))
191}
192
193#[cfg(test)]
194mod tests {
195 //! Covers extension, wildcard MIME, and exact MIME acceptance.
196
197 use super::*;
198
199 #[test]
200 fn accepts_extension_and_mime_patterns() {
201 let accept = UploadAccept::new([".png", "image/*"]);
202 let mut png = UploadFile::named("avatar.PNG");
203 assert!(accept.accepts(&png));
204 png.name = "avatar.webp".into();
205 png.mime_hint = Some("image/webp".into());
206 assert!(accept.accepts(&png));
207 png.mime_hint = Some("application/pdf".into());
208 assert!(!accept.accepts(&png));
209 }
210}