1use std::collections::{BTreeMap, btree_map::Entry};
4use std::fmt;
5
6use crate::CanonicalIdentity;
7use crate::pack::{Pack, font_container_path};
8use crate::payload::SharedBytes;
9
10#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub struct PackExtractionSelection {
16 packages: bool,
17 fonts: bool,
18}
19
20impl PackExtractionSelection {
21 pub const fn new(packages: bool, fonts: bool) -> Self {
23 Self { packages, fonts }
24 }
25
26 pub const fn packages(self) -> bool {
28 self.packages
29 }
30
31 pub const fn fonts(self) -> bool {
33 self.fonts
34 }
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum PackExtractionEntryRole {
40 ProjectFile,
42 PackageFile,
44 FontContainer,
46}
47
48impl fmt::Display for PackExtractionEntryRole {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 formatter.write_str(match self {
51 Self::ProjectFile => "project file",
52 Self::PackageFile => "package file",
53 Self::FontContainer => "font container",
54 })
55 }
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct PackExtractionEntry {
61 relative_path: String,
62 role: PackExtractionEntryRole,
63 bytes: SharedBytes,
64}
65
66impl PackExtractionEntry {
67 pub fn relative_path(&self) -> &str {
69 &self.relative_path
70 }
71
72 pub fn role(&self) -> PackExtractionEntryRole {
74 self.role
75 }
76
77 pub fn len(&self) -> u64 {
79 u64::try_from(self.bytes.len()).unwrap_or(u64::MAX)
80 }
81
82 pub fn is_empty(&self) -> bool {
84 self.bytes.is_empty()
85 }
86
87 pub fn bytes(&self) -> &[u8] {
89 self.bytes.as_slice()
90 }
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct PackExtractionPlan {
99 pack_identity: CanonicalIdentity,
100 selection: PackExtractionSelection,
101 entries: Vec<PackExtractionEntry>,
102}
103
104impl PackExtractionPlan {
105 pub fn pack_identity(&self) -> &CanonicalIdentity {
107 &self.pack_identity
108 }
109
110 pub fn selection(&self) -> PackExtractionSelection {
112 self.selection
113 }
114
115 pub fn entries(&self) -> &[PackExtractionEntry] {
117 &self.entries
118 }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
123#[non_exhaustive]
124pub enum PackExtractionPlanIssue {
125 #[error(
127 "extraction path {first_path:?} ({first_role}) conflicts with {second_path:?} ({second_role})"
128 )]
129 PathConflict {
130 first_path: String,
131 first_role: PackExtractionEntryRole,
132 second_path: String,
133 second_role: PackExtractionEntryRole,
134 },
135}
136
137impl PackExtractionPlanIssue {
138 fn sort_key(&self) -> (u8, &str, u8, &str) {
139 match self {
140 Self::PathConflict {
141 first_path,
142 first_role,
143 second_path,
144 second_role,
145 } => (
146 role_index(*first_role),
147 first_path,
148 role_index(*second_role),
149 second_path,
150 ),
151 }
152 }
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct PackExtractionPlanError {
158 issues: Vec<PackExtractionPlanIssue>,
159}
160
161impl PackExtractionPlanError {
162 pub fn issues(&self) -> &[PackExtractionPlanIssue] {
164 &self.issues
165 }
166}
167
168impl fmt::Display for PackExtractionPlanError {
169 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170 if let [issue] = self.issues.as_slice() {
171 return issue.fmt(formatter);
172 }
173 write!(
174 formatter,
175 "Pack Extraction planning failed with {} issue(s)",
176 self.issues.len()
177 )?;
178 for issue in &self.issues {
179 write!(formatter, ": {issue}")?;
180 }
181 Ok(())
182 }
183}
184
185impl std::error::Error for PackExtractionPlanError {}
186
187pub fn plan_pack_extraction(
189 pack: &Pack,
190 selection: PackExtractionSelection,
191) -> Result<PackExtractionPlan, PackExtractionPlanError> {
192 let mut entries = BTreeMap::new();
193 let mut issues = Vec::new();
194 for (path, _) in pack.files() {
195 add_entry(
196 &mut entries,
197 path.to_owned(),
198 PackExtractionEntryRole::ProjectFile,
199 pack.shared_file(path)
200 .expect("a Pack project file has shared bytes")
201 .clone(),
202 &mut issues,
203 );
204 }
205
206 if selection.packages() {
207 for (spec, files) in pack.packages() {
208 let base = format!("packages/{}/{}/{}", spec.namespace, spec.name, spec.version);
209 for (path, _) in files {
210 add_entry(
211 &mut entries,
212 format!("{base}/{path}"),
213 PackExtractionEntryRole::PackageFile,
214 pack.shared_package_file(spec, path)
215 .expect("an embedded Package Tree file has shared bytes")
216 .clone(),
217 &mut issues,
218 );
219 }
220 }
221 }
222
223 if selection.fonts() {
224 for font in pack.fonts() {
225 add_entry(
226 &mut entries,
227 font_container_path(font.identity().container(), Some(font.data())),
228 PackExtractionEntryRole::FontContainer,
229 font.shared_data().clone(),
230 &mut issues,
231 );
232 }
233 }
234
235 collect_tree_conflicts(&entries, &mut issues);
236 if !issues.is_empty() {
237 issues.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
238 return Err(PackExtractionPlanError { issues });
239 }
240
241 Ok(PackExtractionPlan {
242 pack_identity: pack.identity(),
243 selection,
244 entries: entries.into_values().collect(),
245 })
246}
247
248fn add_entry(
249 entries: &mut BTreeMap<String, PackExtractionEntry>,
250 relative_path: String,
251 role: PackExtractionEntryRole,
252 bytes: SharedBytes,
253 issues: &mut Vec<PackExtractionPlanIssue>,
254) {
255 match entries.entry(relative_path) {
256 Entry::Occupied(existing) if existing.get().role != role => {
257 issues.push(PackExtractionPlanIssue::PathConflict {
258 first_path: existing.key().clone(),
259 first_role: existing.get().role,
260 second_path: existing.key().clone(),
261 second_role: role,
262 });
263 }
264 Entry::Occupied(_) => {}
265 Entry::Vacant(entry) => {
266 let relative_path = entry.key().clone();
267 entry.insert(PackExtractionEntry {
268 relative_path,
269 role,
270 bytes,
271 });
272 }
273 }
274}
275
276fn collect_tree_conflicts(
277 entries: &BTreeMap<String, PackExtractionEntry>,
278 issues: &mut Vec<PackExtractionPlanIssue>,
279) {
280 let mut ancestors = Vec::<(&str, PackExtractionEntryRole)>::new();
281
282 for (relative_path, entry) in entries {
283 while ancestors
284 .last()
285 .is_some_and(|(ancestor, _)| !is_ancestor(ancestor, relative_path))
286 {
287 ancestors.pop();
288 }
289
290 for (ancestor, ancestor_role) in ancestors.iter().filter(|(_, role)| *role != entry.role) {
291 issues.push(PackExtractionPlanIssue::PathConflict {
292 first_path: (*ancestor).to_owned(),
293 first_role: *ancestor_role,
294 second_path: relative_path.clone(),
295 second_role: entry.role,
296 });
297 }
298
299 ancestors.push((relative_path, entry.role));
300 }
301}
302
303fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
304 descendant
305 .strip_prefix(ancestor)
306 .is_some_and(|suffix| suffix.starts_with('/'))
307}
308
309fn role_index(role: PackExtractionEntryRole) -> u8 {
310 match role {
311 PackExtractionEntryRole::ProjectFile => 0,
312 PackExtractionEntryRole::PackageFile => 1,
313 PackExtractionEntryRole::FontContainer => 2,
314 }
315}