1use std::collections::{BTreeMap, BTreeSet};
4
5pub trait TreeSource {
10 fn read(&self, path: &str) -> Option<Vec<u8>>;
11 fn is_file(&self, path: &str) -> bool;
12 fn is_dir(&self, path: &str) -> bool;
13
14 fn children(&self, dir: &str) -> Vec<String>;
16}
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct FileTree {
21 files: BTreeMap<String, Vec<u8>>,
22 children: BTreeMap<String, BTreeSet<String>>,
23}
24
25impl FileTree {
26 pub fn new() -> Self {
27 Self::default()
28 }
29
30 pub fn from_files(
31 files: impl IntoIterator<Item = (String, Vec<u8>)>,
32 ) -> Result<Self, TreePathError> {
33 let mut tree = Self::new();
34 for (path, bytes) in files {
35 tree.insert(path, bytes)?;
36 }
37 Ok(tree)
38 }
39
40 pub fn insert(
41 &mut self,
42 path: impl Into<String>,
43 bytes: impl Into<Vec<u8>>,
44 ) -> Result<(), TreePathError> {
45 let path = path.into();
46 validate_file_path(&path)?;
47 let components = path.split('/').collect::<Vec<_>>();
48 let has_file_ancestor =
49 (1..components.len()).any(|end| self.files.contains_key(&components[..end].join("/")));
50 let descendant_prefix = format!("{path}/");
51 let has_file_descendant = self
52 .files
53 .range(path.clone()..)
54 .next()
55 .is_some_and(|(file, _)| file.starts_with(&descendant_prefix));
56 if has_file_ancestor || has_file_descendant {
57 return Err(TreePathError {
58 path,
59 reason: "a path cannot be both a file and a directory",
60 });
61 }
62 index_children(&mut self.children, &path);
63 self.files.insert(path, bytes.into());
64 Ok(())
65 }
66
67 pub fn files(&self) -> &BTreeMap<String, Vec<u8>> {
68 &self.files
69 }
70}
71
72impl TreeSource for FileTree {
73 fn read(&self, path: &str) -> Option<Vec<u8>> {
74 self.files.get(path).cloned()
75 }
76
77 fn is_file(&self, path: &str) -> bool {
78 self.files.contains_key(path)
79 }
80
81 fn is_dir(&self, path: &str) -> bool {
82 path.is_empty() || self.children.contains_key(path)
83 }
84
85 fn children(&self, dir: &str) -> Vec<String> {
86 self.children
87 .get(dir)
88 .map(|children| children.iter().cloned().collect())
89 .unwrap_or_default()
90 }
91}
92
93pub struct SubTree<'a, T: TreeSource> {
95 inner: &'a T,
96 prefix: String,
97}
98
99impl<'a, T: TreeSource> SubTree<'a, T> {
100 pub fn new(inner: &'a T, prefix: impl Into<String>) -> Self {
102 Self {
103 inner,
104 prefix: prefix.into(),
105 }
106 }
107
108 fn full(&self, path: &str) -> String {
109 match (self.prefix.is_empty(), path.is_empty()) {
110 (true, _) => path.to_owned(),
111 (false, true) => self.prefix.clone(),
112 (false, false) => format!("{}/{path}", self.prefix),
113 }
114 }
115}
116
117impl<T: TreeSource> TreeSource for SubTree<'_, T> {
118 fn read(&self, path: &str) -> Option<Vec<u8>> {
119 self.inner.read(&self.full(path))
120 }
121
122 fn is_file(&self, path: &str) -> bool {
123 self.inner.is_file(&self.full(path))
124 }
125
126 fn is_dir(&self, path: &str) -> bool {
127 self.inner.is_dir(&self.full(path))
128 }
129
130 fn children(&self, dir: &str) -> Vec<String> {
131 let skip = if self.prefix.is_empty() {
132 0
133 } else {
134 self.prefix.len() + 1
135 };
136 self.inner
137 .children(&self.full(dir))
138 .into_iter()
139 .map(|child| child[skip..].to_owned())
140 .collect()
141 }
142}
143
144fn index_children(index: &mut BTreeMap<String, BTreeSet<String>>, path: &str) {
145 let components = path.split('/').collect::<Vec<_>>();
146 for child_index in 0..components.len() {
147 let parent = components[..child_index].join("/");
148 let child = components[..=child_index].join("/");
149 index.entry(parent).or_default().insert(child);
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
154#[error("invalid package tree path `{path}`: {reason}")]
155pub struct TreePathError {
156 pub path: String,
157 pub reason: &'static str,
158}
159
160impl TreePathError {
161 pub fn code(&self) -> &'static str {
163 "tree.invalid-path"
164 }
165}
166
167fn validate_file_path(path: &str) -> Result<(), TreePathError> {
168 let invalid = |reason| TreePathError {
169 path: path.to_owned(),
170 reason,
171 };
172 if path.is_empty() {
173 return Err(invalid("a file path cannot be empty"));
174 }
175 if path.starts_with('/') {
176 return Err(invalid("paths must be package-relative"));
177 }
178 if path.contains('\\') {
179 return Err(invalid("paths must use `/` separators"));
180 }
181 if path
182 .split('/')
183 .any(|component| component.is_empty() || matches!(component, "." | ".."))
184 {
185 return Err(invalid("paths must be normalized"));
186 }
187 Ok(())
188}